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.

Trust boundaries in a production authentication system The browser logs in against the auth server and sends every API call through a backend-for-frontend; both server components read and write a shared session, token and key store, and the gateway forwards only verified requests to the resource APIs. Browser / SPA untrusted runtime Auth server issues session or tokens BFF / API gateway the only trust decision Resource APIs assume verified Session, refresh-token and signing-key store the revocation point — Redis, Postgres or a JWKS service login cookie verified look up / revoke
The four trust boundaries every authentication design has to name: the untrusted client, the credential issuer, the single component that makes the trust decision, and the store that makes revocation possible.

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.

Revocation latency of three session models after a logout event A timeline showing that deleting a server-side session record takes effect immediately, a refresh-token revocation takes effect when the short access token expires, and a long-lived stateless JWT with no blocklist stays valid until its own expiry. Time from "revoke" to "the credential stops working" revoke pressed +15 min +8 hours +7 days Server session store record deleted — next request is anonymous Refresh + short JWT access token dies at its own expiry, refresh already dead Long-lived JWT still accepted by every resource server — nothing to delete
Revocation latency is the number that should drive the state model. If your compliance answer to "log this user out everywhere" has to be "within seconds", a bare long-lived JWT cannot give it to you.

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.

The JWT validation ladder and the attack each rung blocks Six sequential validation steps — algorithm allowlist, key selection by key id from a pinned JWKS, signature verification, issuer check, audience check and expiry check — each paired with the attack that skipping it enables. Verify in this order Skipping it allows 1 · Reject any alg outside the allowlist alg:none and HS256 forgery 2 · Select the key by kid from pinned JWKS key injection via jku or embedded jwk 3 · Verify the signature over header.payload arbitrary claim tampering 4 · Check iss equals your issuer exactly tokens minted by another tenant 5 · Check aud contains this API token replay across your own services 6 · Check exp and nbf with small skew indefinite reuse of a stolen token
Every rung of the ladder maps to a published attack class. A library that "just verifies the token" without an explicit algorithm allowlist has quietly skipped rung one.
// 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.

Where a browser credential can be stored and what each location exposes A four-column comparison of localStorage, sessionStorage, in-memory JavaScript variables and HttpOnly cookies against readability by injected script, survival of a page reload, and automatic sending with cross-site requests. Readable by injected script Survives a reload Sent automatically localStorage yes yes no sessionStorage yes per tab no in memory same realm no no HttpOnly cookie no yes yes — needs CSRF defence The only row an attacker cannot script around is the first one.
Every storage choice trades one attack class for another: script-readable storage trades XSS exposure for CSRF immunity, and cookies do the reverse. Cookies win because CSRF has a complete, cheap defence and XSS exfiltration does not.

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.

Backend-for-frontend login sequence A four-lane sequence: the browser asks the backend-for-frontend to log in, is redirected to the identity provider, returns with an authorization code, and the backend exchanges the code for tokens server-side before setting an opaque session cookie. Browser BFF server Identity provider GET /login 302 with PKCE challenge + state user authenticates at the provider redirect with code back-channel code exchange Set-Cookie: opaque session, HttpOnly
Nothing on the browser lane is a token. The code exchange happens over a back channel the browser never sees, so an XSS bug in the SPA cannot steal a credential that was never delivered to it.

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.

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.

Session cookie attributes and the failure each one prevents Five cookie attributes — HttpOnly, Secure, SameSite, Path and __Host prefix — each shown with the concrete failure that appears when it is missing. Set-Cookie: __Host-sid=...; HttpOnly; Secure; SameSite=Lax; Path=/ HttpOnly blocks XSS read Secure blocks plaintext leak SameSite=Lax blocks silent CSRF Path=/ scopes the cookie __Host- prefix blocks subdomain set Drop one attribute and you get a specific, reproducible bug no HttpOnly: one XSS steals every session · no Secure: one http:// link leaks it SameSite=None without Secure: browsers reject the cookie outright and login silently fails
The attribute set is not a style choice. Each one closes a distinct, demonstrable hole, and the __Host- prefix is what stops a compromised subdomain from writing a session cookie your main origin will accept.

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.

Overlapping signing-key rotation without downtime Three phases of key rotation: the new key is published to the JWKS while the old key still signs, then signing switches to the new key while the old key remains verifiable, and only after every issued token has expired is the old key withdrawn. 1 · Publish new kid appears in JWKS old key still signs 2 · Switch new key signs everything old key still verifies 3 · Withdraw old kid leaves the JWKS after max token age The failure mode: skipping phase 1 Rotating the key and the JWKS in one step invalidates every token still in flight, and every verifier with a cached JWKS returns 401 until its cache expires — a self-inflicted outage.
Rotation is safe only when the overlap window is longer than your longest token lifetime plus the longest JWKS cache TTL among your verifiers.

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.