NextJS Authentication: JWT vs Session Tokens
Every authentication system must answer a fundamental question: where do you store user identity information—on the server (sessions) or in the token itself (JWT)? Sessions are stateful and database-backed; JWT tokens are stateless and self-contained. Each approach has distinct tradeoffs in security, scalability, complexity, and operational overhead. Understanding the comparison helps you choose the right strategy for your application.
What Are JWTs and How Do They Work?
A JSON Web Token (JWT) is a compact, URL-safe string encoding three components separated by dots: header.payload.signature. The header specifies the signing algorithm; the payload contains user claims (user ID, role, email); the signature proves the token has not been tampered with. When a user logs in, the server creates a JWT by signing the payload with a secret key and sends it to the client. On every subsequent request, the client includes the token in the Authorization header. The server verifies the signature—if valid, the token is trusted without querying the database.
Example JWT structure:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsIm5hbWUiOiJKb2huIERvZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYyMzI0NzA2MH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The payload is base64-encoded (not encrypted), so anyone can read it. The signature proves the server issued the token and it has not been modified. JWTs do not require a database lookup—the server can validate them cryptographically.
What Are Sessions and How Do They Work?
A session is a server-side record linking a unique session ID to user data. When a user logs in, the server creates a session, stores it in a database or cache, and sends the session ID to the client as a cookie. On every request, the browser automatically includes the cookie; the server looks up the session and retrieves the user's data. Unlike JWTs, sessions require a database lookup on every request but are revokable instantly.
Comparison Table
| Aspect | JWT | Sessions |
|---|---|---|
| Storage | Stateless; token contains user data | Stateful; data stored on server |
| Database queries | No per-request lookup | One per-request lookup |
| Logout speed | Logout is ineffective until token expires | Instant; delete the session |
| Revocation | Cannot revoke; token valid until expiry | Can revoke immediately |
| Payload size | Large (base64-encoded claims) | Small (only session ID in cookie) |
| Scaling | No server state; easily horizontally scaled | Requires shared session store (Redis) |
| Cross-domain | Can be used across multiple domains | Cookies restricted by Same-Origin Policy |
| XSS risk | High; token in localStorage is vulnerable | Low; token in HTTP-only cookie is safe |
| Security | Token cannot contain sensitive data | Sensitive data stays server-side |
| Refresh mechanism | Requires refresh token pattern | Automatic via session refresh |
Session-Based Authentication in Next.js (Recommended)
For most Next.js applications, sessions are the recommended approach. They integrate naturally with HTTP cookies, allow instant logout, and keep sensitive data on the server. Here is a complete session implementation:
// lib/sessions.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function createSession(userId: string, role: string) {
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 AND expires_at > NOW()',
[sessionId]
);
return result.rows[0] || null;
}
export async function deleteSession(sessionId: string) {
await pool.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
}
Session-based auth is ideal when:
- You need instant logout capabilities.
- You have a centralized database or Redis cache.
- The application is monolithic or server-rendered (not distributed across multiple servers with no shared state).
- Sensitive data (roles, permissions) changes frequently and needs to take effect immediately.
JWT Authentication in Next.js (Advanced Use Case)
JWTs are appropriate when you need a stateless system (no server-side session store) or are building a public API consumed by multiple clients. Here is a JWT implementation:
// lib/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function createJWT(payload: { userId: string; role: string; email: string }) {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
return jwt;
}
export async function verifyJWT(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null;
}
}
And a login route that returns a JWT:
// app/api/login/route.ts (JWT version)
import { NextRequest, NextResponse } from 'next/server';
import { createJWT } from '@/lib/jwt';
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
// Validate credentials (omitted for brevity)
if (email !== '[email protected]') {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const jwt = await createJWT({ userId: 'user-123', role: 'user', email });
return NextResponse.json({ token: jwt });
}
The client stores the token in localStorage and includes it in the Authorization header on subsequent requests. Middleware validates the token:
// middleware.ts (JWT version)
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT } from '@/lib/jwt';
export async function middleware(request: NextRequest) {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = await verifyJWT(token);
if (!payload) {
return NextResponse.redirect(new URL('/login', request.url));
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId as string);
requestHeaders.set('x-user-role', payload.role as string);
return NextResponse.next({ request: { headers: requestHeaders } });
}
JWTs are appropriate when:
- Building a public API consumed by multiple frontends (web, mobile, third-party).
- You need truly stateless authentication (no database).
- Scaling across multiple, independent servers with no shared state.
- Building microservices where each service validates tokens independently.
Token Refresh and Expiration
Both approaches use expiration to limit the validity window if a token is stolen. JWTs use a refresh token pattern: a short-lived access token (15 minutes) and a long-lived refresh token (7 days). When the access token expires, the client uses the refresh token to request a new access token. Sessions use sliding expiry: each request extends the session by a fixed duration if less than a threshold remains.
JWT refresh pattern:
// app/api/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT, createJWT } from '@/lib/jwt';
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get('refresh_token')?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
const payload = await verifyJWT(refreshToken);
if (!payload) {
return NextResponse.json({ error: 'Invalid refresh token' }, { status: 401 });
}
const newAccessToken = await createJWT({
userId: payload.userId as string,
role: payload.role as string,
email: payload.email as string,
});
return NextResponse.json({ accessToken: newAccessToken });
}
This pattern is more complex than session refresh but allows short-lived tokens, reducing the impact of token theft.
Key Takeaways
- Sessions are stateful and database-backed; JWTs are stateless and self-contained.
- Sessions allow instant logout and easier permission updates; JWTs scale without a database.
- For most Next.js applications, sessions are recommended because they integrate with cookies and keep data secure.
- JWTs are appropriate for public APIs, microservices, and applications that need to be stateless.
- Both approaches require secure secret management and HTTPS in production.
- Token expiration is critical; combine short-lived tokens with refresh tokens (JWTs) or sliding expiry (sessions).
Frequently Asked Questions
Can I use both sessions and JWTs in the same app?
Yes. Use sessions for your main web app (better security, simpler logout) and JWTs for your public API (stateless, easier to scale). The browser-based web app uses session cookies; API clients use JWT tokens in headers.
Is JWT more secure than sessions?
Neither is inherently more secure; they have different security profiles. JWTs stored in localStorage are vulnerable to XSS; sessions in HTTP-only cookies are not. JWTs are harder to revoke; sessions are instant. Choose based on your threat model and use case.
Should I store JWTs in localStorage or cookies?
localStorage is vulnerable to XSS—if an attacker injects JavaScript, they can steal the token. HTTP-only cookies prevent JavaScript access. However, HTTP-only cookies work only for same-origin requests. For APIs and cross-domain scenarios, JWTs in the Authorization header are standard, but protect them with short expiry and refresh tokens.
How do I invalidate a JWT before expiration?
You cannot, without a database. If you add a database to track invalidated tokens, you lose the stateless advantage. Solutions include: use short-lived access tokens (15 min) so tokens are invalid quickly, or maintain a token blacklist in Redis (requires a database).