JWT, Login & Session Cookies: A Complete, Modern Guide (2025) Print

  • 0

Who is this for? Application architects, backend/frontend engineers, and DevOps teams who need a practical, secure, production‑ready approach to authentication on web and mobile.


1) Big Picture: Auth, Sessions & Tokens

Authentication verifies who the user is. Authorization controls what they can do. Session management keeps the user authenticated across requests. You'll typically combine:

  • Credential check (e.g., email+password, SSO/OAuth, passkeys)

  • Session persistence (cookie session, JWT access token, refresh token)

  • Transport protection (TLS, Strict headers)

  • Threat controls (CSRF/XSS/mitm/brute‑force defenses)

Two mainstream models:

  1. Server‑Session Model

    • Server stores session (DB/Redis). Client holds an opaque session_id cookie.

    • Pros: Easy revocation, small cookies, mature libraries.

    • Cons: Server state, sticky/replicated store needed at scale.

  2. Token (JWT) Model

    • Server issues short‑lived Access JWT + longer‑lived Refresh token.

    • Pros: Stateless access checks, microservice friendly, works across APIs.

    • Cons: Revocation/rotation is on you; risky if long‑lived; easy to misuse.

Reality: Many modern systems blend both: cookies for transport + JWT for claims; refresh tokens tracked in a DB/Redis for revocation.


2) JWT Basics (JSON Web Tokens)

A JWT is a compact string of three Base64URL parts:

header.payload.signature
  • Header: {"alg":"RS256","typ":"JWT","kid":"<key-id>"}

  • Payload (claims): sub, exp, nbf, iat, jti, iss, aud, scope, roles

  • Signature: created with private key (asymmetric RS256/ES256 recommended) or shared secret (HS256, less preferable across services).

Essential claims

  • iss (issuer), aud (audience), sub (subject - user id), exp (expiry), nbf (not before), iat (issued at), jti (unique id for replay detection and revocation).

Key rotation

  • Publish JWKS (JSON Web Key Set) with active public keys; include kid in JWTs so verifiers choose the right key. Rotate keys periodically.

Lifetimes

  • Access tokens: 5-15 min

  • Refresh tokens: 7-30 days, but rotate on every use and store server‑side to enable revocation.

Alternative: PASETO (v4) removes some JWT foot‑guns. If starting fresh, consider it. Otherwise, use JWT securely.


3) Cookies 101: Attributes That Matter

Cookie anatomy (as sent by server):

Set-Cookie: session=abc123; Path=/; Domain=example.com; HttpOnly; Secure; SameSite=Lax; Max-Age=600
  • Name=Value: Keep values small; avoid sensitive raw data.

  • Path: URL path scope. Usually /.

  • Domain: Scope to a parent domain (e.g., .example.com). Avoid over‑broad domains.

  • Secure: Cookie is sent only over HTTPS.

  • HttpOnly: Not accessible to JS (document.cookie) → mitigates XSS token theft.

  • SameSite: Lax/Strict/None. Controls cross‑site cookie sending → mitigates CSRF.

    • Lax: sent on top-level GET navigations (safe default for login sessions).

    • Strict: never sent cross‑site (best CSRF protection, but UX may suffer).

    • None: must also set Secure; used for third‑party or cross‑site iframes.

  • Max‑Age / Expires: Cookie lifetime. Prefer short life for access, longer for refresh.

  • Priority=High (where supported): Less likely to be evicted under storage pressure.

  • Partitioned (a.k.a. CHIPS): Cross‑site cookies isolated by top‑level site; useful for embedded apps and iframes.

Rule of thumb: Put access token server‑side (session) or in an HttpOnly Secure SameSite cookie. Do not store tokens in localStorage if you can avoid it (XSS risk).


4) CSRF vs XSS: The Two Big Web Threats

  • CSRF: Attacker causes victim's browser to send authenticated cross‑site requests.

    • Defenses: SameSite=Lax/Strict, CSRF tokens (synchronizer/double‑submit), Check Origin/Referer on state‑changing requests, CORS preflights for APIs.

  • XSS: Attacker runs JS in your origin, stealing data/cookies or injecting requests.

    • Defenses: Escaping + CSP, HttpOnly cookies, strict input validation, component sanitization, package supply‑chain hygiene.

SameSite reduces CSRF, but do not rely on it alone. Keep a CSRF token for critical actions or when SameSite=None is required (e.g., cross‑site iframes).


5) End‑to‑End Login Flow (Cookie + JWT Hybrid)

Goal: Short‑lived access JWT for APIs; HttpOnly cookie transport; refresh token rotation.

5.1 Sequence

  1. POST /login with email+password (over TLS). Apply rate limiting + captcha/soft lockouts after N failures.

  2. Server verifies credentials (Argon2id/Bcrypt). If 2FA enabled, challenge.

  3. Issue access JWT (5-10 min) and refresh token (DB‑backed record with jti, device, IP, UA, last‑used).

  4. Set cookies:

    • Set-Cookie: access_token=<jwt>; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600

    • Set-Cookie: refresh_token=<opaque_id>; HttpOnly; Secure; Path=/auth; SameSite=Strict; Max-Age=2592000

  5. Client calls APIs with cookie‑based auth (server reads tokens from cookies). Alternatively, use Authorization: Bearer <access_jwt> for non‑browser clients.

  6. When access token expires, client calls POST /auth/refresh. Server validates the refresh_token against DB (jti + status), issues new access JWT and rotates refresh (old invalidated).

  7. Logout → delete cookies (Max‑Age=0) and mark refresh as revoked.

If your frontend is on a different site (e.g., app.example.com vs API on api.example.com), ensure CORS + SameSite=None; Secure and consider Partitioned cookies if embedded.


6) Example: Express.js (TypeScript) Secure Setup

Illustrative code only; adjust for your stack. Uses cookie transport, Argon2, CSRF, and rotation.

// auth.ts
import express from 'express';
import jwt from 'jsonwebtoken';
import cookie from 'cookie';
import argon2 from 'argon2';
import { nanoid } from 'nanoid';
import rateLimit from 'express-rate-limit';

const router = express.Router();
const ACCESS_TTL = 10 * 60; // 10 min
const REFRESH_TTL = 30 * 24 * 60 * 60; // 30 days
const ISSUER = 'https://auth.example.com';
const AUD = 'example-web';
const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!; // PEM
const JWT_ALG: jwt.Algorithm = 'RS256';

// pretend DB
const users = new Map<string, { id: string; email: string; pwHash: string }>();
const refreshStore = new Map<string, { uid: string; expiresAt: number; revoked: boolean }>();

const loginLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 20 });

function signAccess(userId: string) {
  const now = Math.floor(Date.now() / 1000);
  const jti = nanoid();
  return jwt.sign({ sub: userId, aud: AUD, jti }, JWT_PRIVATE_KEY, {
    algorithm: JWT_ALG,
    issuer: ISSUER,
    expiresIn: ACCESS_TTL,
  });
}

function setCookie(res: express.Response, name: string, value: string, opts?: Partial<cookie.CookieSerializeOptions>) {
  res.append('Set-Cookie', cookie.serialize(name, value, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    ...opts,
  }));
}

router.post('/login', loginLimiter, express.json(), async (req, res) => {
  const { email, password } = req.body;
  const user = Array.from(users.values()).find(u => u.email === email);
  if (!user || !(await argon2.verify(user.pwHash, password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  // (Optional) If TOTP enabled: verify here, else 401.

  const access = signAccess(user.id);
  const rid = nanoid();
  refreshStore.set(rid, { uid: user.id, expiresAt: Date.now() + REFRESH_TTL * 1000, revoked: false });

  setCookie(res, 'access_token', access, { maxAge: ACCESS_TTL, sameSite: 'lax', path: '/' });
  setCookie(res, 'refresh_token', rid, { maxAge: REFRESH_TTL, sameSite: 'strict', path: '/auth' });
  return res.json({ ok: true });
});

router.post('/refresh', async (req, res) => {
  const rid = req.cookies?.refresh_token;
  if (!rid) return res.status(401).json({ error: 'No refresh token' });
  const rec = refreshStore.get(rid);
  if (!rec || rec.revoked || rec.expiresAt < Date.now()) return res.status(401).json({ error: 'Invalid refresh' });

  // rotate
  rec.revoked = true;
  const newRid = nanoid();
  refreshStore.set(newRid, { uid: rec.uid, expiresAt: Date.now() + REFRESH_TTL * 1000, revoked: false });

  const access = signAccess(rec.uid);
  setCookie(res, 'access_token', access, { maxAge: ACCESS_TTL, sameSite: 'lax', path: '/' });
  setCookie(res, 'refresh_token', newRid, { maxAge: REFRESH_TTL, sameSite: 'strict', path: '/auth' });
  return res.json({ ok: true });
});

router.post('/logout', (req, res) => {
  const rid = req.cookies?.refresh_token;
  if (rid) {
    const rec = refreshStore.get(rid);
    if (rec) rec.revoked = true;
  }
  setCookie(res, 'access_token', '', { maxAge: 0 });
  setCookie(res, 'refresh_token', '', { maxAge: 0, path: '/auth' });
  res.json({ ok: true });
});

export default router;

Key points

  • HttpOnly+Secure cookies for both tokens.

  • SameSite=Lax for access (navigations OK) and Strict for refresh endpoint.

  • Refresh rotation + revocation on logout.

  • Use Argon2id with memory‑hard parameters; store password hash, never raw.


7) CORS & Cookie Transport

When frontend and API are on different origins:

  • CORS: allow origin(s), credentials, and required headers.

  • Use fetch(url, { credentials: 'include' }) on the client.

  • Cookies must have SameSite=None; Secure to cross sites.

Express CORS example

import cors from 'cors';
app.use(cors({
  origin: ['https://app.example.com'],
  credentials: true,
  methods: ['GET','POST','PUT','DELETE'],
  allowedHeaders: ['Content-Type','Authorization','X-CSRF-Token']
}));

8) CSRF Token Pattern (Double‑Submit)

  • On login, also set csrf_token cookie (non‑HttpOnly) with random value.

  • Client reads and echoes it as X-CSRF-Token header on state‑changing requests.

  • Server verifies cookie.csrf_token === header['X-CSRF-Token'].

Set cookie

Set-Cookie: csrf_token=rnd...; Path=/; Secure; SameSite=Lax; Max-Age=7200

9) Session Hardening & UX

  • Device‑session table: track user_id, jti/rid, ua, ip, created_at, last_seen, revoked → show user a "Where you're logged in" page; allow revoke.

  • Brute‑force throttling: progressive delays, captcha after N failures.

  • Remember me: create a separate long‑lived, rotated refresh with user consent.

  • 2FA: TOTP, WebAuthn/passkeys for high‑value accounts.

  • Email link login: short‑lived signed links (magic links) with throttling.

  • Session fixation: always rotate cookies on privilege changes/login.

  • IP/ASN anomalies: optional risk scoring; don't break roaming users.


10) Headers & Security Baseline

  • Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

  • Content-Security-Policy: default-src 'self'; frame-ancestors 'none' (tailor to app)

  • X-Frame-Options: DENY (or CSP frame-ancestors)

  • X-Content-Type-Options: nosniff

  • Referrer-Policy: strict-origin-when-cross-origin

  • Permissions-Policy: camera=(), geolocation=(), microphone=()


11) OAuth2/OIDC in a Nutshell

  • Prefer OIDC for standardized login, discovery, and JWKS.

  • Use Authorization Code + PKCE for SPAs and mobile.

  • Store ID token/access token in HttpOnly cookies if browser‑based, or keep them ephemeral in memory.

  • Map IdP claims to app roles; never trust role blindly--enforce server‑side.


12) Logout Models

  • App logout: delete session & refresh records; clear cookies.

  • Global logout: if using OIDC, call the IdP's end‑session endpoint.

  • Browser cache: also clear client state (Redux/Zustand); avoid sensitive data in storage.


13) Key & Token Management

  • Private keys in an HSM/KMS or secret manager, not in repos.

  • Rotate: new key → publish in JWKS → sign new tokens with new kid → retire old.

  • Blacklist/denylist: store jti on logout/abuse for remainder of exp window.

  • Clock skew: allow ± 60s.


14) Testing & Troubleshooting

  • Decode JWTs locally (e.g., jwt.io) without pasting secrets.

  • Time drift? Check NTP.

  • Cookies missing? Check Secure, SameSite, Domain, path, and CORS credentials.

  • 401 after refresh? Ensure rotation works and the old token is revoked.

  • Safari/ITP quirks: consider Partitioned cookies for embedded apps.


15) Minimal Schemas

users

(id pk, email unique, password_hash, twofa_secret nullable, created_at, updated_at)

refresh_tokens

(id pk, user_id fk, jti unique, user_agent, ip, created_at, last_used_at, expires_at, revoked boolean)

roles / user_roles (RBAC)

(role_id pk, name unique)
(user_id fk, role_id fk)

16) Example NGINX Snippets

# force TLS
server {
  listen 80;
  server_name example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.com;

  # HSTS
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

  # Proxy to app
  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://app:3000;
  }
}

17) Mobile & Native Clients

  • Prefer Auth Code + PKCE with an in‑app browser for SSO.

  • Store refresh tokens in the OS secure enclave/keystore; keep access tokens short‑lived.

  • Avoid embedding client secrets.


18) Compliance & Logging

  • Log auth events: login success/failure, refresh, logout, password change, 2FA setup, admin escalations.

  • Pseudonymize IPs/user agents where required by privacy law.

  • Retention policies per compliance (e.g., 90/180 days).


19) Quick Checklists

Login Endpoint

  • Argon2id hash

  • Rate‑limit & captcha on repeated failures

  • Rotate on login (new cookies)

  • Optional 2FA & recovery codes

Cookies

  • HttpOnly; Secure; SameSite=Lax/Strict as appropriate

  • Short Max‑Age for access, longer for refresh

  • Proper Domain & Path

JWT

  • RS256/ES256 + kid + JWKS

  • iss, aud, sub, exp, nbf, iat, jti

  • 5-15 min access TTL

Refresh

  • DB‑backed with rotation

  • Device/session page & revocation

CORS/CSRF

  • credentials: include, allowed origins

  • CSRF token for state‑changing routes

Headers

  • HSTS, CSP, Referrer‑Policy, X‑CTO


20) Patterns to Avoid

  • Long‑lived access tokens (risk of theft).

  • Storing tokens in localStorage/sessionStorage if you can use cookies.

  • Missing refresh rotation (enables unlimited replay if stolen).

  • Putting PII inside JWT payload. Anyone with the token can read it.

  • Using SameSite=None without Secure.


21) Appendix: Example Set-Cookie Receipts

# After login
Set-Cookie: access_token=eyJ...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600
Set-Cookie: refresh_token=R_0lJ...; Path=/auth; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000
Set-Cookie: csrf_token=T9y...; Path=/; Secure; SameSite=Lax; Max-Age=7200

# On logout
Set-Cookie: access_token=""; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0
Set-Cookie: refresh_token=""; Path=/auth; HttpOnly; Secure; SameSite=Strict; Max-Age=0

22) Final Recommendations

  1. Prefer short‑lived access JWTs, rotating refresh tokens, and HttpOnly cookies.

  2. Keep a server‑side record of refresh tokens for revocation and device management.

  3. Harden with CSP + SameSite + CSRF tokens + rate limiting + 2FA.

  4. Document your key rotation & incident response playbooks.

  5. Consider PASETO for simpler cryptography semantics if starting anew.


You can drop this article directly into your docs. Add your platform‑specific snippets (e.g., NestJS guards, Next.js middleware, or NGINX/Cloudflare rules) where relevant.


Was this answer helpful?

« Back