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)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. 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:

  1. POST /auth/register — accepts email and password, hashes password with bcrypt cost 12, stores in memory
  2. POST /auth/login — verifies credentials, returns JWT access token (15 min) and refresh token (7 days)
  3. POST /auth/refresh — accepts a refresh token, returns a new access token (rotates refresh)
  4. GET /me — protected route, returns { userId, role } from the verified access token
  5. POST /auth/logout — adds the refresh token to a Redis blacklist with 7-day TTL
  6. Include role-based authorization: GET /admin/users returns 403 unless the user's role is admin
  7. Return structured errors: { error: { code, message } }
  8. Use JWT_ACCESS_SECRET and JWT_REFRESH_SECRET from 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

6. Next Steps


Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse