Modern Authentication Fundamentals
Modern authentication has outgrown “check the password, set a cookie”: it is now a distributed, protocol-driven system where one missing cookie flag, one unvalidated JWT header, or one session identifier that survives a login can expose every account you have. This guide sets out the engineering fundamentals — the state model, the token internals, the transport hardening, and the revocation machinery — that a production identity system needs before it is safe to put in front of real users.
Architecture Overview
A production authentication system is best read as a set of trust boundaries rather than a stack of libraries. The browser is an untrusted runtime you ship code to but cannot vouch for. The authentication server mints credentials. A shared store holds session records, refresh-token families, and signing keys. The gateway or backend-for-frontend sits between the client and your resource APIs and is the only component that gets to say “this request is authenticated”. Every arrow crossing those boundaries is an attack surface that needs an explicit, testable control.
The sections below walk that diagram from left to right: the state model you pick, the protocol internals underneath it, how clients store credentials without leaking them, how the server hardens transport, and finally how you prove you can revoke, rotate, and detect compromise. Newer systems fold in phishing-resistant credentials and second factors, covered in the passkeys and WebAuthn walkthrough and the multi-factor authentication guide.
Stateful vs Stateless Identity Models
Choosing between a server-side session and a self-contained token is the decision every other control inherits. It sets your revocation latency, your storage exposure, your horizontal-scaling cost, and how much work a resource server has to do per request. The deep dive on session vs token authentication walks the full comparison; what follows is the decision framework.
Architectural Trade-offs
Stateful sessions rely on a cryptographically random identifier carried in a cookie (RFC 6265) and mapped to a record in a shared store — Redis, PostgreSQL, or DynamoDB. The identifier is opaque: it carries no claims, so a leak reveals nothing about the user, and deleting the record ends the session everywhere in the time it takes the store to replicate. The costs are a lookup on every request and a store that must be available, or your whole application is.
Stateless tokens (RFC 7519) invert that. Identity claims are embedded in a signed payload, so any resource server holding the issuer’s public key can verify a request with no network round trip. That is exactly why revocation is hard: the token stays valid until it expires, no matter what your database thinks. Production stateless designs therefore always pair a short access-token lifetime (5–15 minutes is typical) with a longer-lived refresh token that is stateful, giving you a revocation point without a per-request lookup.
Choosing From Your Revocation SLA
Work backwards from the requirement instead of the technology. If an administrator disabling an account must lock that user out within seconds — the usual answer for finance, healthcare, and any system with an insider-threat model — you need either a server-side session or a per-request introspection call. If your APIs are consumed by mobile clients over flaky networks and a 15-minute compromise window is acceptable, short access tokens with rotating refresh tokens give you most of the scaling benefit and a real revocation story. What does not work is a multi-hour access token with no blocklist and a “log out” button that only clears client state.
Protocol Standards and Token Anatomy
OAuth 2.0 (RFC 6749) and OpenID Connect are the backbone of delegated authorization and federated authentication; JOSE (RFC 7515/7519) defines what a token actually is. Getting the anatomy right matters because most token vulnerabilities are not cryptographic breaks — they are validation steps someone skipped.
Cryptographic Verification and Claim Validation
Verification is a fixed ladder of checks, and every rung has a documented attack behind it. Decode the header, but never trust it; select the key by kid from a pinned JWKS rather than from anything the token asserts; enforce an explicit algorithm allowlist; then validate issuer, audience, expiry, and not-before with a small clock tolerance.
// Production JWT validation (Node.js / jose)
import { jwtVerify, createRemoteJWKSet } from "jose";
// The JWKS is fetched from a pinned URL and cached — never from a URL inside the token.
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"), {
cooldownDuration: 30_000, // rate-limit refetches when an unknown kid appears
cacheMaxAge: 10 * 60_000, // bounded staleness so key rotation lands within minutes
});
export async function validateAccessToken(token: string) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256", "ES256"], // explicit allowlist — never derived from the token
clockTolerance: "30s", // tolerate NTP drift, nothing more
maxTokenAge: "15m", // defence in depth against an over-long exp
});
if (!payload.sub) throw new Error("token has no subject");
return { subject: payload.sub, scopes: String(payload.scope ?? "").split(" "), kid: protectedHeader.kid };
}
The algorithm allowlist is the single most important line in that function. Without it, a verifier that accepts HS256 will happily validate a token signed with the public RSA key as an HMAC secret — the classic algorithm confusion attack — and one that accepts alg: none will validate anything at all. Reject unknown kid values rather than falling back to “try every key”, and refuse tokens whose header carries an embedded jwk or a jku pointing anywhere but your own issuer.
Scopes, Audiences and Least Privilege
An access token is a capability, and its blast radius is defined by aud and scope. Issue one audience per API rather than one token that every service accepts, so a compromised low-value service cannot replay its token against your billing API. Keep scopes verb-shaped and coarse enough to reason about (read:invoices, write:invoices) and enforce them at the gateway and in the service, because a gateway rule is a configuration file and configuration files drift. The mapping from an identity provider’s claims to your own permission model is covered in mapping OIDC claims to application roles.
Client-Side Identity Integration
The browser is the part of your system an attacker can most easily influence, so the rule is simple: the client should hold as little credential material as possible, for as short a time as possible, in a place script cannot read.
Never persist an access or ID token in localStorage or sessionStorage: any injected script reads them synchronously and exfiltrates them before your monitoring notices. Prefer an HttpOnly, Secure cookie set by the server, and pair it with the CSRF controls described in mitigating CSRF attacks in modern SPAs. Where a token genuinely has to live in JavaScript — a pure client-side integration with a third-party API — keep it in a closure variable that never touches storage, and reduce the value of stealing it by keeping the lifetime in minutes.
A strict Content Security Policy is what keeps the XSS surface from ever mattering, and it is the control most often written once and never enforced:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none';
base-uri 'none'; frame-ancestors 'none'; form-action 'self'; require-trusted-types-for 'script'
The base-uri 'none' directive stops an injected <base> tag redirecting your own relative script URLs, and form-action 'self' stops an injected form posting credentials off-origin — both are routinely omitted. The full treatment, including sanitisation and framework-specific escapes, is in preventing XSS in auth workflows. Mobile clients get the equivalent guarantee from platform key stores: the iOS Keychain and Android Keystore hold refresh material behind a biometric gate so a compromised app process cannot read it directly.
Backend-for-Frontend and API Gateway Patterns
The backend-for-frontend pattern moves every token out of the browser. The single-page app talks only to a same-origin server it was served from; that server runs the authorization code flow with PKCE, keeps the resulting tokens server-side, and hands the browser nothing but an opaque HttpOnly session cookie.
That single change removes the entire class of “attacker exfiltrates the access token” bugs, at the cost of a stateful component in front of your APIs. It also gives you one place to enforce rate limits, IP reputation checks, request logging, and session introspection. The logout path deserves equal care: destroying the local session while leaving the upstream refresh token alive means a stolen refresh token outlives the user’s logout.
Secure Transport and Cookie Configuration
Cookie attributes are the cheapest security controls in the stack and the most frequently misconfigured. HttpOnly removes the cookie from the DOM; Secure keeps it off plaintext connections; SameSite decides whether a cross-site request carries it at all; Path and Domain decide which of your own applications can see it. Each has a failure mode that is invisible in a happy-path test.
Enforce TLS 1.3 everywhere, add HSTS with includeSubDomains and preload, and let the reverse proxy make plaintext impossible rather than trusting every application to set Secure:
server {
listen 443 ssl;
http2 on;
ssl_protocols TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
location / {
proxy_pass http://backend;
proxy_cookie_flags ~ HttpOnly Secure SameSite=Lax; # belt and braces at the edge
}
}
Reach for SameSite=None only when a genuine cross-site flow needs it — an embedded widget or a cross-domain SSO handoff — and never without Secure, because browsers reject the pair outright. The trade-offs and the partitioned-cookie replacement are covered in how to set SameSite=None for cross-site cookies, and the per-framework configuration lives in configuring secure cookie flags in production.
Scaling, Revocation and Key Rotation
An authentication system that cannot rotate its signing keys without downtime is one incident away from an outage, and one that cannot revoke a credential is one incident away from a breach notification. Both problems are solved by publishing more than one valid key at a time and by giving every credential a lifetime short enough that expiry does most of your revocation work.
Give every verifier a bounded JWKS cache (five to ten minutes) with a rate-limited refetch on an unknown kid, so a rotation propagates quickly without letting an attacker trigger unbounded fetches. On the session side, key the store on a random 128-bit identifier, store the user id, the issue time, an absolute expiry, and a device fingerprint, and index by user so “sign out everywhere” is a single ranged delete. Emit a structured audit event on every issue, refresh, and revoke; the refresh-token reuse detection pattern turns those events into an actual theft alarm rather than a log nobody reads.
Observability for identity systems needs one extra rule: sanitise before you ship. Authorization headers, cookies, and token bodies must never reach your log pipeline in the clear.
const REDACT = [/authorization:\s*bearer\s+\S+/gi, /cookie:\s*[^\n]+/gi, /eyJ[\w-]+\.[\w-]+\.[\w-]+/g];
export function sanitize(line: string): string {
return REDACT.reduce((acc, re) => acc.replace(re, "[redacted]"), line);
}
A Threat Model You Can Actually Work Through
Abstract threat modelling stalls. A concrete one works because each entry names an attacker capability, the control that removes it, and the test that proves the control is live. Work down this list for any authentication system and you will find the gaps quickly.
The attacker can run script in your page. This is the assumption to start from, because a single third-party dependency, a stored comment, or a mis-escaped template makes it true. The control is that no credential is readable from JavaScript: sessions in HttpOnly cookies, tokens held by a backend-for-frontend, and a Content Security Policy strict enough that injected script cannot load. The test is a browser console typing document.cookie and finding nothing useful.
The attacker can make the victim’s browser send a request. Any site can trigger a cross-site form post or an image load against your origin. The control is SameSite on the session cookie plus an anti-CSRF token on every state-changing request, and the test is a hand-written HTML form on a different origin posting to your endpoint and being rejected.
The attacker has a valid credential for their own account. Most privilege-escalation bugs come from here: an identifier in a URL that is not checked against the caller, an admin endpoint that only hides its link, or a role claim the client is trusted to send. The control is authorization checked server-side on every object access, covered in preventing privilege escalation in API endpoints.
The attacker has stolen a credential. Assume a token or session identifier leaks — through a proxy log, a shared machine, or a phishing page. The controls are short lifetimes, rotation, binding to a device or client fingerprint, and detection: an alert when the same refresh token is presented twice, or when a session is used from two distant locations within minutes.
The attacker can read your database. Backups get copied, replicas get exposed, and a SQL injection is one bad query away. The control is that nothing in the store is directly usable: passwords and recovery codes hashed with a memory-hard function, session records that expire, and refresh tokens stored as hashes rather than plaintext so a dump is not a set of working credentials.
The attacker is a legitimate user of a neighbouring tenant. In multi-tenant systems the boundary is the tenant identifier, and the failure is a query that filters by object id but not by tenant. The control is a tenant claim in the session and a data-access layer that refuses to run a query without it.
Each of those has a matching monitoring hook, and the hook is what turns a control into an operational fact. Alert on repeated authentication failures for one account, on any use of a recovery code, on refresh-token reuse, on a spike in 401 responses from a single client version, and on any change to authorization data — the audit trail described in auditing permission changes with an append-only log is what lets you answer “when did this account gain that role?” months later.
Compliance and Standards Alignment
Most audit questions map onto a small set of specifications, and knowing which control answers which requirement saves an enormous amount of time when the questionnaire arrives.
| Control | Standard | What an auditor actually asks for |
|---|---|---|
| Session identifier entropy and regeneration | OWASP ASVS V3.2, V3.3 | Proof the identifier is CSPRNG-generated and changes on login |
| Cookie attributes | RFC 6265bis, ASVS V3.4 | A response capture showing HttpOnly, Secure, SameSite |
| Token validation | RFC 7519, RFC 8725 | The algorithm allowlist and issuer/audience checks in code |
| Authorization code flow | RFC 6749, RFC 7636 | PKCE enforced for every client, including confidential ones |
| Token revocation | RFC 7009 | A revocation endpoint call on logout, and evidence it is invoked |
| Multi-factor assurance | NIST SP 800-63B AAL2/AAL3 | Which factors satisfy which assurance level, and step-up rules |
| Credential storage | ASVS V2.4 | The password/recovery-code hashing algorithm and parameters |
Compliance work goes faster when the evidence is a byproduct of the system rather than a document someone writes at audit time. Emit a structured event for every credential lifecycle action — issue, refresh, revoke, factor enrolled, factor removed, password changed, role granted — with a stable schema, and the answer to most control questions becomes a query rather than an investigation. Keep those events in append-only storage with a retention period that matches your longest regulatory obligation, and make sure they record the actor, the target, the source address, and the assurance level of the session that performed the action.
Authentication Mechanism Decision Matrix
There is no single correct credential model; the right choice is the one whose revocation latency, storage exposure, and phishing resistance match your threat model.
| Requirement | Server-side session | Stateless JWT | Passkey (WebAuthn) | TOTP / FIDO2 second factor |
|---|---|---|---|---|
| Instant revocation | Native — delete the store record | Needs a blocklist or short TTL + refresh | Per-credential delete | Not applicable — augments a primary factor |
| Horizontal scaling | Needs a shared store or sticky routing | Local verification, no lookup | Local verification of the assertion | Local verification |
| Phishing resistance | None (shared secret) | None (bearer token) | Strong — origin-bound by the browser | FIDO2 strong; TOTP weak |
| Primary storage risk | XSS-safe in an HttpOnly cookie, CSRF-prone |
Exfiltratable if persisted in JavaScript | Private key never leaves the authenticator | Shared secret at rest |
| Best fit | Classic web apps, admin portals | Microservice and mobile APIs | Passwordless primary login | Hardening an existing password login |
| Control to pair with it | Secure cookie flags | CSRF mitigation for cookie-borne JWTs | Registration ceremony | Step-up enforcement |
Most production systems combine several rows: an HttpOnly session cookie or a BFF-held token as the carrier, a passkey or password as the primary factor, and a second factor demanded on sensitive actions. Whatever the mix, regenerate the session identifier on every privilege change and keep the cookie scope as narrow as your cross-origin requirements allow.
Frequently Asked Questions
Should a new application default to sessions or to JWTs?
Default to a server-side session in an HttpOnly cookie unless you have a specific reason not to. It gives you instant revocation, no client-side credential storage, and a single place to inspect and terminate access — and the “it doesn’t scale” objection is mostly folklore, because a Redis lookup costs well under a millisecond next to the database queries the request is about to make anyway. Reach for tokens when you genuinely have independent resource servers that cannot share a session store, or non-browser clients where cookies are awkward.
Is a JWT in an HttpOnly cookie better than a JWT in localStorage?
Yes, meaningfully. The cookie is unreadable by injected script, so an XSS bug can act as the user while the page is open but cannot exfiltrate a credential to use later from somewhere else. The trade is that cookies are sent automatically, so you take on CSRF, which has a complete defence in SameSite=Lax plus a synchronizer or double-submit token. Exchanging an unsolvable problem for a solved one is a good trade.
How short should an access token's lifetime be?
Short enough that expiry is your primary revocation mechanism: 5–15 minutes for browser-facing APIs, up to an hour for service-to-service calls where the client can re-authenticate silently. The lifetime is a direct statement of how long a stolen token stays useful, so pair it with a refresh token that is stored server-side, rotated on every use, and revoked the moment reuse is detected.
Do I still need CSRF protection if I use SameSite=Lax?
Yes. SameSite=Lax blocks the classic cross-site form post, but it does not cover same-site subdomain attacks, it does not apply to top-level GET navigations that your application treats as state-changing, and browser defaults vary by version and by user configuration. Treat it as defence in depth alongside an explicit anti-CSRF token on every state-changing request, as described in the CSRF walkthrough.
What is the first thing to fix in a legacy authentication system?
Session identifier regeneration on login, then cookie attributes. Regeneration closes session fixation — an attacker planting a known identifier before the victim authenticates — and it is usually a one-line change in the framework. Cookie attributes are equally cheap and close the two loudest remaining holes: script access and plaintext transmission. Only after those are in place is it worth arguing about token formats.
Related
- Understanding session vs token authentication — stateful and stateless trade-offs, revocation latency, and scaling costs.
- Configuring secure cookie flags in production —
HttpOnly,Secure,SameSiteand prefix hardening per framework. - Mitigating CSRF attacks in modern SPAs — double-submit and synchronizer-token defences for cookie sessions.
- Preventing XSS in auth workflows — CSP, sanitisation, and safe credential storage.
- Implementing passkeys and WebAuthn — phishing-resistant, origin-bound passwordless login.
- Multi-factor authentication with TOTP and FIDO2 — second factors, assurance levels, and step-up rules.
- Preventing session fixation and hijacking — regenerating identifiers and binding sessions to context.