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:
-
Server‑Session Model
-
Server stores session (DB/Redis). Client holds an opaque
session_idcookie. -
Pros: Easy revocation, small cookies, mature libraries.
-
Cons: Server state, sticky/replicated store needed at scale.
-
-
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
kidin 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 setSecure; 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
localStorageif 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=Noneis 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
-
POST /login with email+password (over TLS). Apply rate limiting + captcha/soft lockouts after N failures.
-
Server verifies credentials (Argon2id/Bcrypt). If 2FA enabled, challenge.
-
Issue access JWT (5-10 min) and refresh token (DB‑backed record with
jti, device, IP, UA, last‑used). -
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
-
-
Client calls APIs with cookie‑based auth (server reads tokens from cookies). Alternatively, use
Authorization: Bearer <access_jwt>for non‑browser clients. -
When access token expires, client calls POST /auth/refresh. Server validates the
refresh_tokenagainst DB (jti + status), issues new access JWT and rotates refresh (old invalidated). -
Logout → delete cookies (
Max‑Age=0) and mark refresh as revoked.
If your frontend is on a different site (e.g.,
app.example.comvs API onapi.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+Securecookies for both tokens. -
SameSite=Laxfor access (navigations OK) andStrictfor 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; Secureto 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_tokencookie (non‑HttpOnly) with random value. -
Client reads and echoes it as
X-CSRF-Tokenheader 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 CSPframe-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
roleblindly--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
jtion logout/abuse for remainder ofexpwindow. -
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 CORScredentials. -
401after 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/Strictas appropriate -
Short
Max‑Agefor 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/sessionStorageif 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=NonewithoutSecure.
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
-
Prefer short‑lived access JWTs, rotating refresh tokens, and HttpOnly cookies.
-
Keep a server‑side record of refresh tokens for revocation and device management.
-
Harden with CSP + SameSite + CSRF tokens + rate limiting + 2FA.
-
Document your key rotation & incident response playbooks.
-
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.