Authentication Patterns — JWT, Sessions, and OAuth
Status: Active | Last Updated: 2026-08-29 Category: Backend — Authentication Prerequisites: Middleware basics Tags: auth, jwt, session, oauth, password, bcrypt Estimated Time: 5-6 hours (Self-paced, includes lab time)
Summary
Authentication answers "who are you?" Authorization answers "what can you do?" This article covers the most common authentication patterns: session cookies, JWT tokens, and OAuth/OIDC. It also covers password storage (never store plain text — always hash) and refresh token rotation.
What You'll Learn (Core Competencies)
- Distinguish between authentication (who) and authorization (what) and implement each correctly
- Implement session-based authentication with HTTP-only cookies and server-side session storage
- Issue and verify JWTs (JSON Web Tokens) using HMAC signing
- Implement refresh token rotation for long-lived sessions without re-authentication
- Hash passwords with bcrypt and verify them correctly
- Implement OAuth2 Authorization Code flow with PKCE for SPAs and mobile apps
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Authentication (AuthN): "Who are you?" — verify identity using credentials (username + password, token, biometric). This answers the question of identity.
Authorization (AuthZ): "What can you do?" — verify permissions after identity is established. This uses roles (isAdmin), policies, or attributes to decide whether an action is allowed.
A login flow authenticates the user. A role check (isAdmin) authorizes an action. Never skip authentication to check authorization — the order is always AuthN then AuthZ.
Two primary patterns for maintaining identity across requests:
Session-based: The server stores session data (userId, roles, expiry) in a server-side store (Redis, database). The client receives a session ID in an HTTP-only cookie. On every request, the server looks up the session by ID. Revocation is immediate — delete the session record.
Token-based (JWT): The server signs a token containing user claims. The client stores the token and sends it on every request. The server verifies the signature without querying a database. Revocation is harder — the token is valid until expiry — so short expiry with refresh tokens is the standard pattern.
2. Deep Dive & Implementation
Session Authentication
// Session middleware
async function sessionMiddleware(request: Request): Promise<Session | null> {
const sid = request.headers.get("Cookie")?.split(";")
.find(c => c.trim().startsWith("sid="))?.split("=")[1];
if (!sid) return null;
const sess = await redis.get(`sess:${sid}`);
if (!sess) return null;
const session = JSON.parse(sess);
// Refresh expiry on activity
await redis.expire(`sess:${sid}`, 3600);
return session;
}
// Login endpoint
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.hash)) {
return res.status(401).json({ error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" } });
}
const sid = crypto.randomUUID();
await redis.setex(`sess:${sid}`, 3600, JSON.stringify({ userId: user.id, role: user.role }));
res.setHeader("Set-Cookie", `sid=${sid}; HttpOnly; SameSite=Strict; Path=/`);
res.json({ ok: true });
});
// Logout endpoint
app.post("/auth/logout", async (req, res) => {
const cookies = req.headers.cookie ?? "";
const sid = cookies.split(";").map(c => c.trim()).find(c => c.startsWith("sid="))?.split("=")[1];
if (sid) await redis.del(`sess:${sid}`);
res.setHeader("Set-Cookie", "sid=; HttpOnly; Max-Age=0");
res.json({ ok: true });
});
Sessions scale across instances when stored in Redis (or another shared store). Never use in-memory session storage in multi-server deployments — different requests may hit different servers and would not see the same session.
JWT Authentication
import { sign, verify } from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
interface TokenPayload {
userId: number;
role: string;
}
// Issue tokens
function issueTokens(userId: number, role: string) {
const accessToken = sign({ userId, role }, ACCESS_SECRET, { expiresIn: "15m" });
const refreshToken = sign({ userId }, REFRESH_SECRET, { expiresIn: "7d" });
return { accessToken, refreshToken };
}
// Verify access token
function verifyAccess(token: string): TokenPayload {
return verify(token, ACCESS_SECRET) as TokenPayload;
}
// Verify refresh token
function verifyRefresh(token: string): { userId: number } {
return verify(token, REFRESH_SECRET) as { userId: number };
}
JWTs carry claims signed with HMAC (HS256) or RSA (RS256). Use short expiry (15 min) with refresh tokens (7 days). Store tokens in memory (not localStorage) to reduce XSS exposure; prefer Authorization: Bearer header over cookies for stateless APIs. Always verify iss, aud, exp.
Refresh Token Rotation
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: "Missing refreshToken" });
let payload;
try {
payload = verifyRefresh(refreshToken);
} catch {
return res.status(401).json({ error: { code: "INVALID_TOKEN", message: "Refresh token invalid or expired" } });
}
// Check blacklist for revoked tokens
const revoked = await redis.get(`revoked:${refreshToken}`);
if (revoked) return res.status(401).json({ error: { code: "REVOKED", message: "Token has been revoked" } });
const user = await db.users.findById(payload.userId);
if (!user) return res.status(401).json({ error: "User not found" });
// Issue new access token; rotate refresh token
const tokens = issueTokens(user.id, user.role);
// Revoke old refresh token
await redis.setex(`revoked:${refreshToken}`, 7 * 24 * 3600, "1");
res.json(tokens);
});
OAuth2 / OIDC Concepts
OAuth2 delegates authorization; OpenID Connect adds identity. Authorization Code flow with PKCE for mobile/SPA is the standard: redirect to provider, user consents, code returned, exchange for access + ID tokens. Use state parameter to prevent CSRF.
// Initiate OAuth
app.get("/auth/oauth", (req, res) => {
const url = new URL("https://provider.com/oauth/authorize");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", "openid profile email");
url.searchParams.set("state", crypto.randomBytes(16).toString("hex"));
url.searchParams.set("code_challenge", pkceChallenge);
res.redirect(url.toString());
});
// Handle callback
app.get("/auth/oauth/callback", async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we stored at /auth/oauth
// Exchange code for tokens at provider's token endpoint
// Verify ID token, extract user info
// Create local session or issue JWT
});
Password Hashing
Use bcrypt (or Argon2id) with adaptive cost. Never compare with ===. Verify with bcrypt.compare().
import bcrypt from "bcrypt";
// Hash on registration
const hash = await bcrypt.hash(plainPassword, 12); // cost factor 12
// Verify on login
const ok = await bcrypt.compare(plainPassword, storedHash);
if (!ok) return res.status(401).json({ error: "Invalid credentials" });
Cost factor 12 is the current recommendation (each increment doubles work). Argon2id is preferred for new applications.
Guided Checkpoint
Verify the auth flow with a complete test suite:
# Start: bun run server.ts
# Register a user with hashed password — returns 201
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct horse battery staple"}' | jq .
# Login with correct credentials — sets session cookie, returns 200
curl -s -c cookies.txt -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct horse battery staple"}' | jq .
# Access protected route with cookie — returns 200
curl -s -b cookies.txt http://localhost:3000/profile | jq .
# Login with wrong password — returns 401
curl -s -w "\n%{http_code}" -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"wrong"}'
# Login with weak password (less than 8 chars) — returns 400
curl -s -w "\n%{http_code}" -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"bob@example.com","password":"123"}'
# Logout — clears cookie
curl -s -b cookies.txt -X POST http://localhost:3000/auth/logout
# Access protected route after logout — returns 401
curl -s -w "\n%{http_code}" -b cookies.txt http://localhost:3000/profile
# Verify stored password is hashed (not plain text)
# In DB: check users.password_hash column starts with "$2b$12$..."
All responses must have correct status codes. Passwords in the database must be bcrypt hashes (starting with $2b$12$), never plain text.
3. Anti-Patterns & Common Pitfalls
Storing passwords in plain text. If the database leaks, every user's password is exposed. Always hash with bcrypt or Argon2id before storing.
Comparing passwords with === or ==. Plain text comparison is not just insecure — it is also vulnerable to timing attacks. Always use bcrypt.compare(), which is constant-time.
Using a short or predictable session ID. Session IDs must be cryptographically random (crypto.randomUUID() or 32+ bytes from crypto.randomBytes()). Predictable IDs let attackers hijack other users' sessions.
Storing JWTs in localStorage. JavaScript running on the page can read localStorage. A single XSS vulnerability exposes every token. Use HTTP-only cookies for refresh tokens, or keep access tokens in memory only.
Not setting HttpOnly on session cookies. Without HttpOnly, the cookie is accessible to JavaScript. A reflected XSS vulnerability can steal the session ID. Always set HttpOnly; Secure; SameSite=Strict on auth cookies.
Using the same secret for access and refresh tokens. Compromise of one secret compromises both. Use distinct secrets with different key rotation policies.
Not validating iss and aud claims on JWTs. Without these checks, a JWT issued for one application can be replayed against another that shares the same secret. Always validate.
Auth-then-authorize checks on every endpoint without fail. A missing role check on an admin route is a privilege escalation vulnerability. Centralize authorization in middleware so it cannot be skipped per route.
4. Independent Challenge
Build a complete authentication system for a REST API with the following requirements:
POST /auth/register— acceptsemailandpassword, hashes password with bcrypt cost 12, stores in memoryPOST /auth/login— verifies credentials, returns JWT access token (15 min) and refresh token (7 days)POST /auth/refresh— accepts a refresh token, returns a new access token (rotates refresh)GET /me— protected route, returns{ userId, role }from the verified access tokenPOST /auth/logout— adds the refresh token to a Redis blacklist with 7-day TTL- Include role-based authorization:
GET /admin/usersreturns403unless the user's role isadmin - Return structured errors:
{ error: { code, message } } - Use
JWT_ACCESS_SECRETandJWT_REFRESH_SECRETfrom environment variables
Use jsonwebtoken for JWTs, bcrypt for password hashing, and an in-memory array (or Redis if available) for user storage.
5. Consolidation & Key Invariants
- Always hash passwords with bcrypt (cost ≥ 12) or Argon2id; never store plain text
- Compare passwords with
bcrypt.compare(), never===— constant-time comparison prevents timing attacks - Set session cookies with
HttpOnly; Secure; SameSite=Strictto prevent XSS and CSRF attacks - Use distinct secrets for access tokens and refresh tokens; rotate secrets independently
- Validate JWT
iss,aud, andexpclaims on every verify — never trust a token without full validation - Always check authorization (role/permission) after authentication; AuthN then AuthZ
- Store refresh tokens in a revocation list (Redis) so logout and theft response are immediate
- Generate session IDs with
crypto.randomUUID()or 32+ random bytes — never use sequential or predictable IDs
6. Next Steps
- Configure JWT refresh rotation with a revocation list backed by Redis.
- Implement OAuth2 with PKCE for third-party login (Google, GitHub).
- Add password strength validation (length, complexity, breach database lookup).
- Set up session revocation endpoint and admin user management.
- Read
kb/backend/production-backend.mdfor rate limiting on login endpoints and brute-force protection. - Read
kb/backend/middleware-patterns.mdfor composing auth checks with other middleware.
Change Log
- 2026-08-29: Initial scaffold
- 2026-08-29: Expanded session, JWT, OAuth, password hashing with code examples
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)