Authentication Patterns for Modern Web Apps: Sessions, JWTs, and When to Use Each
Most tutorials pick JWTs or sessions without explaining the tradeoffs. This covers stateful sessions with Redis, stateless JWTs, refresh token rotation, OAuth 2.0 integration, CSRF and XSS attack surfaces per approach, httpOnly cookie handling in Next.js and Hono, and a decision framework based on real architecture constraints.
Every web app needs authentication. But “I need auth” is where the decision usually stops in tutorials, followed immediately by “so I used JWTs.” That choice gets made without examining what it costs, what it buys, and whether the architecture it implies actually fits the system being built.
This is not a beginner’s guide to auth. It is a breakdown of the real tradeoffs between stateful sessions and stateless JWTs, how refresh token rotation works in practice, how OAuth 2.0 fits into both models, where CSRF and XSS attacks land per approach, and how to implement httpOnly cookie handling correctly in Next.js and Hono. At the end, there is a concrete decision framework.
The code examples are TypeScript. The opinions are based on building auth systems that have run in production.
The Core Distinction: Stateful vs Stateless
The conceptual split comes down to where trust lives.
In a stateful session, the server owns the session record. A session ID is stored client-side (usually in a cookie). On each request, the server looks up that ID in a store (typically Redis or a database) to verify it is valid and retrieve the associated user data. Revocation is immediate: delete the record from the store and the session is gone.
In a stateless JWT model, the server issues a signed token containing claims (user ID, roles, expiry). On each request, the server verifies the signature and trusts the claims in the token. There is no lookup. The token is self-contained. Revocation requires either waiting for expiry or maintaining a denylist, which reintroduces state.
Neither model is universally correct. The decision depends on your infrastructure, your scaling requirements, and the security guarantees you need.
Stateful Sessions with Redis
A Redis-backed session store is the most reliable, battle-tested approach for most web applications. You get immediate revocation, simple server logic, and a well-understood security model.
// lib/session.ts
import { createClient } from 'redis'
import { randomBytes } from 'crypto'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
interface SessionData {
userId: string
email: string
roles: string[]
createdAt: number
lastActiveAt: number
}
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7 // 7 days
export async function createSession(data: SessionData): Promise<string> {
const sessionId = randomBytes(32).toString('hex')
const key = `session:${sessionId}`
await redis.setEx(key, SESSION_TTL_SECONDS, JSON.stringify(data))
return sessionId
}
export async function getSession(sessionId: string): Promise<SessionData | null> {
const key = `session:${sessionId}`
const raw = await redis.get(key)
if (!raw) return null
// Slide the TTL on activity so active users stay logged in
await redis.expire(key, SESSION_TTL_SECONDS)
return JSON.parse(raw) as SessionData
}
export async function deleteSession(sessionId: string): Promise<void> {
await redis.del(`session:${sessionId}`)
}
export async function deleteAllUserSessions(userId: string): Promise<void> {
// Requires a secondary index: a set of session IDs per user
const userSessionsKey = `user-sessions:${userId}`
const sessionIds = await redis.sMembers(userSessionsKey)
const pipeline = redis.multi()
for (const id of sessionIds) {
pipeline.del(`session:${id}`)
}
pipeline.del(userSessionsKey)
await pipeline.exec()
}
The deleteAllUserSessions function illustrates a real operational need that sessions handle cleanly: “log out all devices” is one Redis operation. With JWTs, there is no equivalent without a denylist.
For the secondary index to work, you need to add session IDs to the user’s set when creating sessions:
export async function createSession(
data: SessionData,
userId: string
): Promise<string> {
const sessionId = randomBytes(32).toString('hex')
const pipeline = redis.multi()
pipeline.setEx(`session:${sessionId}`, SESSION_TTL_SECONDS, JSON.stringify(data))
pipeline.sAdd(`user-sessions:${userId}`, sessionId)
pipeline.expire(`user-sessions:${userId}`, SESSION_TTL_SECONDS)
await pipeline.exec()
return sessionId
}
The pipeline ensures both writes happen atomically. If the process crashes between them, you end up with either a session without an index entry (worst case: “log out all devices” misses one session) or an index entry without a session (harmless: lookup returns null). That asymmetry is acceptable.
Setting the Cookie
// In a Next.js route handler (App Router)
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const { email, password } = await request.json()
const user = await verifyCredentials(email, password)
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const sessionId = await createSession({
userId: user.id,
email: user.email,
roles: user.roles,
createdAt: Date.now(),
lastActiveAt: Date.now(),
}, user.id)
const response = NextResponse.json({ ok: true })
response.cookies.set('session_id', sessionId, {
httpOnly: true, // not accessible via document.cookie
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection for most cases
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return response
}
httpOnly is non-negotiable. It means the cookie cannot be read by JavaScript, which eliminates XSS-based session theft. sameSite: 'lax' provides CSRF protection for state-changing requests from cross-origin navigations. For APIs that need to accept cross-origin requests (like a separate SPA), use sameSite: 'none' with a CSRF token.
Stateless JWTs
JWTs work well for short-lived tokens where revocation is not required or where you can tolerate the revocation latency of a denylist. The canonical use case is a microservices architecture where the API gateway issues tokens and downstream services verify them without a central session store.
// lib/jwt.ts
import { SignJWT, jwtVerify, type JWTPayload } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
const ACCESS_TOKEN_TTL = '15m'
const REFRESH_TOKEN_TTL = '7d'
interface AccessTokenPayload extends JWTPayload {
sub: string // userId
email: string
roles: string[]
}
export async function signAccessToken(
payload: Omit<AccessTokenPayload, keyof JWTPayload>
): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(ACCESS_TOKEN_TTL)
.sign(JWT_SECRET)
}
export async function verifyAccessToken(
token: string
): Promise<AccessTokenPayload> {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload as AccessTokenPayload
}
export async function signRefreshToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(REFRESH_TOKEN_TTL)
.sign(JWT_SECRET)
}
Use jose rather than jsonwebtoken. jose is Web Crypto API-based, works in edge runtimes (Cloudflare Workers, Deno, Next.js middleware), and does not expose the alg: none vulnerability that affected older JWT libraries.
The alg field in the header matters. Always specify it explicitly in both signing and verification. A library that accepts any algorithm the token claims is a vulnerability, not a feature.
Refresh Token Rotation
Short-lived access tokens (15 minutes) limit the window of exposure if a token is stolen. Refresh tokens extend the session without requiring the user to re-authenticate, but they are long-lived and therefore higher-value targets.
Refresh token rotation solves this: each time a refresh token is used, it is replaced. If a stolen refresh token is used before the legitimate user, the server detects the collision (both the old and new tokens become invalid) and can alert or force re-authentication.
This requires server-side state for refresh tokens, which narrows the gap between “stateless JWTs” and sessions in practice.
// lib/refresh-tokens.ts
interface RefreshTokenRecord {
userId: string
tokenHash: string // store hash, not plaintext
parentHash: string | null // for rotation chain detection
createdAt: number
usedAt: number | null
}
import { createHash } from 'crypto'
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export async function storeRefreshToken(
db: Database,
userId: string,
token: string,
parentToken: string | null
): Promise<void> {
await db.refreshTokens.create({
userId,
tokenHash: hashToken(token),
parentHash: parentToken ? hashToken(parentToken) : null,
createdAt: Date.now(),
usedAt: null,
})
}
export async function rotateRefreshToken(
db: Database,
incomingToken: string
): Promise<{ userId: string; newRefreshToken: string; newAccessToken: string } | null> {
const incomingHash = hashToken(incomingToken)
const record = await db.refreshTokens.findByHash(incomingHash)
if (!record) return null
// Reuse detection: this token was already used
if (record.usedAt !== null) {
// Invalidate the entire family tree for this user
await db.refreshTokens.invalidateAllForUser(record.userId)
return null
}
// Mark the incoming token as used
await db.refreshTokens.markUsed(record.id)
// Issue new tokens
const newRefreshToken = await signRefreshToken(record.userId)
const user = await db.users.findById(record.userId)
const newAccessToken = await signAccessToken({
sub: user.id,
email: user.email,
roles: user.roles,
})
// Store new refresh token, linked to parent
await storeRefreshToken(db, record.userId, newRefreshToken, incomingToken)
return { userId: record.userId, newRefreshToken, newAccessToken }
}
The key behavior: when a refresh token is replayed (reuse detection), invalidate the entire session family. This is conservative but correct. A legitimate user’s session gets interrupted, which prompts a re-login. That is a better outcome than letting an attacker maintain access.
OAuth 2.0 Integration
OAuth 2.0 is a delegation protocol, not an authentication protocol. OpenID Connect (OIDC) adds the authentication layer on top. When you add “Sign in with Google,” you are using OIDC over OAuth 2.0.
The Authorization Code flow with PKCE is the correct choice for web apps. Avoid Implicit flow: it passes tokens in URL fragments, which leak into server logs and browser history.
// lib/oauth.ts
import { randomBytes, createHash } from 'crypto'
interface OAuthState {
codeVerifier: string
codeChallenge: string
state: string
redirectUri: string
}
export function generateOAuthState(redirectUri: string): OAuthState {
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
const state = randomBytes(16).toString('hex')
return { codeVerifier, codeChallenge, state, redirectUri }
}
export function buildGoogleAuthUrl(oauthState: OAuthState): string {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: oauthState.redirectUri,
response_type: 'code',
scope: 'openid email profile',
code_challenge: oauthState.codeChallenge,
code_challenge_method: 'S256',
state: oauthState.state,
})
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string
): Promise<{ idToken: string; accessToken: string }> {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
}),
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`)
}
const tokens = await response.json()
return {
idToken: tokens.id_token,
accessToken: tokens.access_token,
}
}
The state parameter is your CSRF protection for the OAuth flow. Store it in the session (or a short-lived cookie) before the redirect and verify it matches on callback. If it does not match, reject the callback: someone may be trying to inject an authorization code.
The codeVerifier and codeChallenge are PKCE. The client generates a random verifier, hashes it to produce the challenge, sends the challenge to the authorization server, then proves ownership by sending the verifier during code exchange. This prevents authorization code interception attacks, which are realistic against mobile apps and browser extensions.
Store the codeVerifier and state in a session or a signed cookie before redirecting. On callback, retrieve and verify both:
// app/api/auth/callback/google/route.ts (Next.js App Router)
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
// Retrieve the pending OAuth state from the session
const cookieStore = await cookies()
const pendingState = cookieStore.get('oauth_state')?.value
const codeVerifier = cookieStore.get('oauth_code_verifier')?.value
if (!code || !state || state !== pendingState || !codeVerifier) {
return NextResponse.redirect('/login?error=invalid_state')
}
const { idToken } = await exchangeCodeForTokens(
code,
codeVerifier,
`${process.env.BASE_URL}/api/auth/callback/google`
)
const userInfo = await verifyGoogleIdToken(idToken)
// Upsert the user and create a session
const user = await upsertUser({ email: userInfo.email, name: userInfo.name })
const sessionId = await createSession({ userId: user.id, email: user.email, roles: user.roles }, user.id)
const response = NextResponse.redirect('/dashboard')
response.cookies.set('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
})
response.cookies.delete('oauth_state')
response.cookies.delete('oauth_code_verifier')
return response
}
CSRF and XSS Attack Surfaces
The attack surface differs substantially between sessions and JWTs, and between cookie storage and localStorage.
XSS (Cross-Site Scripting): An attacker injects JavaScript into your page. If the token is in localStorage, it is gone: localStorage.getItem('token') is all it takes. If it is in a httpOnly cookie, the JavaScript cannot read it. Cookies win on XSS.
CSRF (Cross-Site Request Forgery): An attacker gets a user’s browser to make a request to your site from a different origin. If your authentication is cookie-based and sameSite is not set, the browser sends the cookie with the forged request and the server accepts it. JWTs in Authorization headers are immune to CSRF because the browser does not automatically attach headers to cross-origin requests.
The practical summary:
| Storage | XSS Risk | CSRF Risk |
|---|---|---|
| localStorage | High (readable) | None |
| httpOnly cookie | None (unreadable) | Mitigated by sameSite |
| Memory only | Low (page lifetime) | None (no header auto-attach) |
sameSite: 'lax' covers the majority of CSRF scenarios: it allows the cookie on top-level GET navigations (clicking a link) but not on cross-origin POST, PUT, or DELETE requests. For applications that need full cross-origin API access (a separate SPA calling an API), use sameSite: 'none; Secure' combined with explicit CSRF token validation.
A CSRF token implementation for Hono:
// middleware/csrf.ts
import { createMiddleware } from 'hono/factory'
import { getCookie, setCookie } from 'hono/cookie'
import { randomBytes, timingSafeEqual } from 'crypto'
export const csrfProtection = createMiddleware(async (c, next) => {
// Skip CSRF check for safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
return next()
}
const cookieToken = getCookie(c, 'csrf_token')
const headerToken = c.req.header('X-CSRF-Token')
if (!cookieToken || !headerToken) {
return c.json({ error: 'CSRF token missing' }, 403)
}
// Timing-safe comparison prevents timing attacks
const cookieBuf = Buffer.from(cookieToken)
const headerBuf = Buffer.from(headerToken)
if (
cookieBuf.length !== headerBuf.length ||
!timingSafeEqual(cookieBuf, headerBuf)
) {
return c.json({ error: 'CSRF token mismatch' }, 403)
}
await next()
})
export function generateCsrfToken(): string {
return randomBytes(32).toString('hex')
}
The double-submit cookie pattern: the CSRF token is set in a cookie (readable by JavaScript, not httpOnly) and must also be sent in a custom header. Cross-origin requests cannot set custom headers without a preflight, so the attacker cannot forge the header. Your JavaScript reads the cookie and copies it into the header on each mutating request.
Auth Middleware in Hono
A session-based auth middleware in Hono that works on both Node.js and edge runtimes:
// middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import { getCookie } from 'hono/cookie'
import { getSession } from '../lib/session'
interface AuthVariables {
userId: string
email: string
roles: string[]
}
export const requireAuth = createMiddleware<{ Variables: AuthVariables }>(
async (c, next) => {
const sessionId = getCookie(c, 'session_id')
if (!sessionId) {
return c.json({ error: 'Unauthorized' }, 401)
}
const session = await getSession(sessionId)
if (!session) {
return c.json({ error: 'Session expired' }, 401)
}
c.set('userId', session.userId)
c.set('email', session.email)
c.set('roles', session.roles)
await next()
}
)
export const requireRole = (role: string) =>
createMiddleware<{ Variables: AuthVariables }>(async (c, next) => {
const roles = c.get('roles')
if (!roles?.includes(role)) {
return c.json({ error: 'Forbidden' }, 403)
}
await next()
})
Using it:
const app = new Hono()
app.get('/api/profile', requireAuth, (c) => {
// userId, email, roles are typed and available
const userId = c.get('userId')
return c.json({ userId })
})
app.delete('/api/admin/users/:id', requireAuth, requireRole('admin'), async (c) => {
const targetId = c.req.param('id')
await deleteUser(targetId)
return c.json({ ok: true })
})
The JWT equivalent looks nearly identical but replaces the session lookup with token verification:
export const requireJwtAuth = createMiddleware<{ Variables: AuthVariables }>(
async (c, next) => {
const authHeader = c.req.header('Authorization')
const cookieToken = getCookie(c, 'access_token')
const token = authHeader?.replace('Bearer ', '') ?? cookieToken
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
try {
const payload = await verifyAccessToken(token)
c.set('userId', payload.sub!)
c.set('email', payload.email)
c.set('roles', payload.roles)
await next()
} catch {
return c.json({ error: 'Invalid or expired token' }, 401)
}
}
)
Tradeoffs Summary
| Concern | Stateful Session (Redis) | Stateless JWT |
|---|---|---|
| Revocation | Immediate (delete from store) | Wait for expiry or maintain denylist |
| Server overhead | Redis lookup per request | Cryptographic verify only |
| Horizontal scaling | Requires shared Redis | No shared state needed |
| ”Log out all devices” | Trivial | Requires denylist |
| Token size | Small cookie (32 bytes) | Larger (200-500 bytes typical) |
| Works on edge runtimes | Requires Redis connection | Yes, with Web Crypto |
| Microservices | Requires session propagation | Natural fit (verify at each service) |
| User data staleness | Fresh on every request | Stale until token expires |
| XSS risk (httpOnly cookie) | None | None (if in httpOnly cookie) |
| CSRF risk | Mitigated with sameSite | None (if in Authorization header) |
The “user data staleness” row is underappreciated. If you encode roles: ['admin'] into a JWT and then revoke admin access, the user retains admin claims until the access token expires. Fifteen minutes of unauthorized access is a real security concern for sensitive operations. Sessions read the source of truth on every request.
Decision Framework
Use stateful sessions with Redis when:
- You need immediate revocation (compliance, security-sensitive apps, enterprise accounts)
- You are building a traditional server-rendered or SPA-plus-backend architecture
- You need “log out all devices” or account security features
- Your infrastructure already includes Redis or a database
- User data (roles, plan tier) changes frequently and claims must be fresh
Use stateless JWTs when:
- You are building a microservices architecture where multiple services need to verify identity without a shared store
- You are deploying purely to edge runtimes and cannot maintain a persistent Redis connection
- The tokens are short-lived (under 15 minutes) and revocation latency is acceptable
- You are issuing tokens for machine-to-machine API access where rotation is handled out of band
In practice, many production systems use both: a session cookie for the browser-facing application and JWTs for service-to-service communication. The session validates the browser request; the application server issues a short-lived JWT when calling downstream services. That JWT never touches the browser.
One architecture to be cautious of: long-lived JWTs stored in localStorage as the primary auth mechanism for a user-facing application. It combines the worst of both models: no easy revocation and maximum XSS exposure. If you find yourself doing this, the session model is almost certainly a better fit.
Production Considerations
A few concerns that do not show up in tutorials.
Session fixation: Generate a new session ID after login, even if you stored state in a pre-login session. An attacker who can set a cookie (via a related-domain XSS) can fix a session ID before login and hijack it after.
Secure comparison: Always use timingSafeEqual when comparing tokens or session IDs. String equality is vulnerable to timing attacks that can confirm whether a prefix of a token is correct.
Key rotation for JWTs: When rotating your signing secret, you cannot immediately invalidate tokens signed with the old key. Use a key ID (kid) in the JWT header and maintain a JWKS (JSON Web Key Set) endpoint. Clients fetch current keys and verify against the matching key ID. This lets you rotate keys without invalidating active sessions.
Redis failure modes: If Redis is unavailable and sessions cannot be verified, fail closed (return 401) not open. Log the failure prominently. A Redis outage should be a loud alert, not silent pass-throughs that bypass authentication.
Token storage for mobile: httpOnly cookies are a browser construct. For native mobile apps, store tokens in the platform’s secure keychain (iOS Keychain, Android Keystore), not in shared preferences or application storage.
Authentication is one of the few parts of a web app where getting the threat model wrong has consequences that outlast the sprint. The implementation is not hard. The tradeoffs are what matter. Picking the right model for your constraints from the start is cheaper than migrating sessions to JWTs (or vice versa) six months into production.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.