Skip to main content

NextJS Authentication: Build Secure User Sessions

A session is a server-side record that represents an authenticated user during their visit. In Next.js, sessions are created when a user logs in, encrypted into an HTTP-only cookie sent to the browser, and validated on every subsequent request. Sessions are the foundation of web authentication: they let you recognize who the user is, what permissions they have, and when to log them out. Unlike JWT tokens, sessions store sensitive data on the server, so credentials and roles never travel across the network.

What is a Session and How Does It Work?

A session is a temporary, stateful record linking a unique ID to user metadata. When a user submits login credentials, your server creates a session object, stores it in a database or cache (Redis, PostgreSQL, or in-memory), and sends back a session ID inside an HTTP-only cookie. On every request, the browser automatically includes that cookie; your middleware decrypts it and fetches the corresponding session from storage. If the session is valid and not expired, you trust that request. If it's missing or expired, you redirect to login. This cycle repeats until logout, when you delete the session and clear the cookie.

Session-based auth is the default recommendation for Next.js and most web applications because sessions keep sensitive data server-side, enable instant logout (delete the session immediately), and work naturally with HTTP-only cookies—a security feature that prevents JavaScript from accessing the cookie, blocking XSS attacks. The session ID is the only value in the cookie; the user's actual credentials and roles stay in your database.

Setting Up a Session Store

The first step is choosing where to store session data. For development and small projects, an in-memory store is fine. For production, use a database or cache service like Redis.

Here is a simple in-memory session store (for development only):

// lib/sessions.ts
const sessionStore = new Map<string, { userId: string; expiresAt: number; role: string }>();

export function createSession(userId: string, role: string = 'user') {
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
sessionStore.set(sessionId, { userId, expiresAt, role });
return sessionId;
}

export function getSession(sessionId: string) {
const session = sessionStore.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessionStore.delete(sessionId);
return null;
}
return session;
}

export function deleteSession(sessionId: string) {
sessionStore.delete(sessionId);
}

For production, a database-backed session store is essential. Here is a PostgreSQL example:

// lib/sessions-postgres.ts
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function createSession(userId: string, role: string = 'user') {
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await pool.query(
'INSERT INTO sessions (id, user_id, role, expires_at) VALUES ($1, $2, $3, $4)',
[sessionId, userId, role, expiresAt]
);
return sessionId;
}

export async function getSession(sessionId: string) {
const result = await pool.query(
'SELECT user_id, role, expires_at FROM sessions WHERE id = $1',
[sessionId]
);
if (result.rows.length === 0) return null;
const session = result.rows[0];
if (new Date(session.expires_at) < new Date()) {
await pool.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
return null;
}
return { userId: session.user_id, role: session.role, expiresAt: new Date(session.expires_at).getTime() };
}

export async function deleteSession(sessionId: string) {
await pool.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
}

Once you have a session, encode the session ID into a secure, HTTP-only cookie. Never store sensitive data (passwords, tokens) in cookies; only store the session ID.

// lib/cookies.ts
import { cookies } from 'next/headers';

const SESSION_COOKIE_NAME = 'next_session';
const SESSION_COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds

export async function setSessionCookie(sessionId: string) {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true, // Prevent JS from reading; blocks XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // Prevent CSRF
maxAge: SESSION_COOKIE_MAX_AGE,
path: '/', // Available on all routes
});
}

export async function getSessionCookie() {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
}

export async function clearSessionCookie() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}

The httpOnly: true flag is critical: it prevents JavaScript (both yours and any injected malicious code) from reading the cookie. If an XSS vulnerability lets an attacker execute code in your app, they cannot steal the session ID directly. The browser still includes the cookie in HTTP requests automatically, so your middleware can read and validate it server-side.

Reading and Validating Sessions on Request

Every request must validate the session cookie. Middleware is the ideal place to do this because it runs before route handlers and Server Components, applying the same validation everywhere.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSessionCookie } from './lib/cookies';
import { getSession } from './lib/sessions';

export async function middleware(request: NextRequest) {
// Clone the cookie store from the request (Middleware must use request.cookies, not server cookies())
const sessionId = request.cookies.get('next_session')?.value;

if (sessionId) {
const session = await getSession(sessionId);
if (!session) {
// Session is invalid or expired; redirect to login
return NextResponse.redirect(new URL('/login', request.url));
}
// Session is valid; attach to request headers for downstream handlers
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', session.userId);
requestHeaders.set('x-user-role', session.role);
return NextResponse.next({ request: { headers: requestHeaders } });
}

// No session cookie; allow request (protected routes will check later)
return NextResponse.next();
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Note: In Next.js Middleware, you cannot use the cookies() server function directly. Use request.cookies instead. We then attach user data to request headers so downstream route handlers and Server Components can access it.

Creating a Session on Login

Here is a complete login route that validates credentials and creates a session:

// app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { setSessionCookie } from '@/lib/cookies';
import { createSession } from '@/lib/sessions';

export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();

// TODO: Fetch user from database and validate password
// For this example, we'll assume a hardcoded user
if (email !== '[email protected]' || password !== 'password123') {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}

// Create session (in production, fetch the user's ID and role from the database)
const sessionId = await createSession('user-123', 'user');
await setSessionCookie(sessionId);

return NextResponse.json({ success: true, message: 'Logged in successfully' });
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

Key Takeaways

  • A session is a server-side record that persists user identity; only the session ID travels in a cookie.
  • HTTP-only cookies prevent JavaScript from accessing the session ID, protecting against XSS.
  • Sessions allow instant logout and easier management of user roles and permissions.
  • Store session data in a database or Redis in production; in-memory stores are for development only.
  • Middleware validates the session on every request and enriches the request with user metadata.
  • The session ID must be unique (use UUID) and have a clear expiration date.

Frequently Asked Questions

Why use sessions instead of JWT tokens?

Sessions store sensitive data server-side, so tokens never contain credentials or roles. This allows instant logout (delete the session immediately), easier permission updates (no need to reissue tokens), and reduced attack surface because you control the entire lifecycle. JWTs are stateless and don't require a database, but they delay logout and prevent revoking permissions until the token expires.

Technically yes, but it is not secure. Cookies are signed, not encrypted by default in Next.js, so a malicious user can read the data. HTTP-only cookies prevent JS from reading them, but they are still visible in transit unless you use HTTPS. Always store only the session ID in the cookie; keep user data in a server-side database.

How do I renew or extend a session?

When a user is active, update the expiresAt timestamp in the session record and bump the cookie's maxAge. For example, on every request, check if the session has less than 1 hour remaining and extend it by 7 days. This gives logged-in users a seamless experience while protecting idle sessions from hijacking.

What happens if the session database goes down?

Without a session store, you cannot validate cookies. Design for graceful degradation: cache sessions in Redis with database fallback, or use a distributed cache service. For Vercel deployments, use a managed database like Vercel Postgres.

Further Reading