Skip to main content

Authentication in Practice - Sessions, JWT, OAuth, mTLS, Passkeys

TL;DR
  • Authentication (authN) answers "who are you"; authorization (authZ) answers "what may you do". A failed authN is a 401, a failed authZ is a 403 (HTTP status codes).
  • A session is server-side state with a random id as the handle; a JWT is signed client-held state that any server with the key can check without a lookup. The price of stateless is that you cannot revoke before exp without bringing a store back.
  • OAuth 2.0 is delegated authorization, a valet key for an API (RFC 6749). OpenID Connect adds the login layer: an ID token says who the user is, an access token says what the app may call.
  • For browsers and mobile apps use the authorization code flow with PKCE (RFC 7636, RFC 9700). Implicit and password grants are out.
  • Machines prove identity with mTLS or short-lived signed tokens; people should use passkeys (WebAuthn), because the signature is bound to the site's origin and a phishing page cannot reuse it.

Mental model​

ANALOGYEveryday

A hotel. At the front desk you show your ID once (authentication) and get a key card. The card does not say who you are in words; it opens your room and the gym but not the room next door (authorization). It works until checkout (token expiry). Staff can also hand a valet key to the parking attendant: it starts the car but does not open the trunk or the glovebox. That is OAuth: you let an app act on part of your account without giving it your password.

Where it breaks: a hotel can deactivate a lost key card in seconds because the lock checks a central list. A self-contained signed token has no central list to check, so a lost one keeps working until it expires. And a hotel clerk sees your face; a website only ever sees whatever secret you present, so whoever holds the secret is you.

ANALOGYFor engineers

A session id is a pointer into a server-side map: sessions[sid] -> user. A JWT is a signed struct passed by value: the receiver verifies the checksum instead of dereferencing anything. OAuth is a capability with a limited scope, like handing a function a read-only file descriptor instead of the whole process's permissions. mTLS is each service holding an identity document that a trusted authority signed, checked at connection time.

Where it breaks: a pointer can be invalidated by deleting the map entry; a by-value token cannot be recalled, so any real revocation design (deny-list, short expiry plus a refresh lookup) reintroduces the map. And unlike a file descriptor, a bearer token is not bound to the process that received it: whoever copies the string can use it.

How it works​

Authentication, authorization and the 401/403 split​

Authentication establishes an identity: a user, a service, a device. Authorization decides whether that identity may perform a specific action on a specific resource. They fail differently. RFC 9110 uses 401 for "no valid credentials" (and requires a WWW-Authenticate header naming the scheme) and 403 for "I understood who you are, and the answer is no". The mistake to avoid is answering 403 when the real problem is a missing or expired token, because clients key their re-login logic off the 401.

Password login, step by step​

  1. The browser posts the username and password over TLS (TLS). The password is protected in transit only; the server must see it once, in the clear, to check it.
  2. The server looks up the stored password hash and recomputes it. Passwords are never stored or compared as plaintext. The OWASP Password Storage Cheat Sheet recommends Argon2id (its minimum configuration is 19 MiB of memory, 2 iterations, 1 degree of parallelism), with scrypt, bcrypt and PBKDF2 as fallbacks. A slow, memory-hard hash is the point: it makes a stolen database expensive to crack. Salting, pepper and the hash algorithms are explained on Symmetric crypto and hashing.
  3. On a match the server creates a credential and hands it back. There are two families, shown next.
Session cookieBearer token
What the server returnsSet-Cookie: __Host-sid=...A token in the response body
Who attaches it laterThe browser, automaticallyYour code, in Authorization: Bearer ... (RFC 6750)
What it isOpaque id; the facts live in a server storeOpaque id or a self-contained JWT
Main browser riskCSRF, because the cookie is sent for youXSS, because script must hold the token

The cookie mechanics (flags, SameSite, CSRF defences) and the JWT anatomy (header, payload, signature, base64url) are owned by Cookies, sessions, tokens and CORS. This page assumes them and covers what sits around them.

What a JWT check actually does​

A JWT is signed, not encrypted: the payload is readable by anyone, and the signature only proves the first two parts were not changed (RFC 7519). The verifier runs a fixed list of checks, in this order, and rejects on the first failure (RFC 8725 section 3 is the checklist).

1. Pin algorithm2. Pick the key3. Signature4. iss and aud5. exp and nbfignore header algkid, via JWKSrecompute, comparemust match configallow small skewstops alg=nonestops wrong keystops edited tokenstops wrong audiencestops expired tokenAny failed check: reject the token and answer 401 with WWW-Authenticate
Each box is one check the verifier performs. Under each box is the attack or mistake that check stops. The algorithm comes from your own configuration, never from the token's header.

Worked example: a real HS256 token​

This token was generated with Node's crypto module, HS256 and the throwaway secret demo-secret. It is 203 characters: 36 (header) + 1 + 122 (payload) + 1 + 43 (signature).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDU2MDAwfQ.JAHZ8QMBxvQWZPeZbkbcKN-9ofEzIiQ8pbg6PTPQbew

The header decodes to {"alg":"HS256","typ":"JWT"} and the payload to:

{"iss":"https://auth.example.com","sub":"user-42","aud":"api.example.com","exp":1893456000}

exp is 1893456000, which is 2030-01-01 00:00:00 UTC. The signature is HMAC-SHA256("demo-secret", header + "." + payload), 32 bytes, base64url-encoded without padding to 43 characters. The verifier recomputes it, compares, then checks iss, aud and exp against its own configuration. The Hands-on section reproduces every step in a shell.

OAuth 2.0 authorization code flow with PKCE​

OAuth answers a delegation problem: a photo-printing site wants to read your photos at another service without ever holding your password there. Four roles (RFC 6749 section 1.1): the resource owner (you), the client (the app), the authorization server (issues tokens) and the resource server (the API). The browser carries the user between the app and the authorization server.

Client appBrowserAuth serverResource server1 302 to /authorize2 GET /authorize3 login + consent4 302 with code5 GET redirect_uri6 POST /token7 200 access_token8 GET /api + Bearer9 200 data
Solid arrows travel through the browser's address bar (front channel), so anything in them can be seen or intercepted. Dashed arrows are direct HTTPS calls between servers (back channel). The code in step 4 is useless to a thief without the verifier that only the app holds.

What each step carries:

StepParameters and what they are for
1-2response_type=code, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method=S256. state is an unguessable value the app checks on return so a forged response is rejected; code_challenge is the PKCE commitment.
3The authorization server authenticates the user (a password, MFA or a passkey) and shows the consent screen. The app never sees the credentials.
4-5The browser is redirected to the exact registered redirect_uri with code and state in the query string. The code is single-use and short-lived (RFC 6749 section 4.1.2 recommends at most 10 minutes).
6The app posts grant_type=authorization_code, code, redirect_uri and the **code_verifier**, plus its client credentials if it is a confidential client. The server hashes the verifier and compares it to the stored challenge.
7The server returns an access token, usually a refresh token, and (with OIDC) an ID token.
8-9The app calls the API with Authorization: Bearer <access_token>; the resource server validates the token and answers.

PKCE (RFC 7636) closes the hole in step 4: on a phone or in a browser, another app or script can sometimes read the redirect and steal the code. The app invents a random verifier (43 to 128 characters), sends only code_challenge = BASE64URL(SHA256(verifier)) in step 1, and reveals the verifier only in step 6. The RFC's own test vector (appendix B):

code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (S256)

A thief who steals the code in step 4 does not have the verifier, so step 6 fails for them.

Deep dive​

HS256, RS256 and ES256​

The alg header chooses how the signature is made. The choice decides who can verify and who can forge (JWA, RFC 7518).

AlgorithmPrimitiveKeySignature sizeWho can verify
HS256HMAC-SHA256One shared secret32 bytes, 43 charactersOnly parties holding the secret, and each of them can also forge
RS256RSASSA-PKCS1-v1_5 with SHA-256RSA key pair (2048-bit modulus)256 bytes, 342 charactersAnyone with the public key; only the issuer can sign
ES256ECDSA on P-256 with SHA-256EC key pair64 bytes, 86 charactersAnyone with the public key; only the issuer can sign

The sizes are derived: base64url turns every 3 bytes into 4 characters without padding, so 32 bytes is 43 characters, 256 bytes is 342 and 64 bytes is 86. With RS256 or ES256 the issuer publishes its public keys as a JWK Set (JWKS, RFC 7517) and tags each token with a kid so verifiers pick the right key; OIDC providers advertise the JWKS location in their discovery document. That is why public-key signatures dominate once more than one service verifies tokens: nobody but the issuer holds signing power. The signature theory is on Key exchange and public-key cryptography.

Attacks on JWT validation​

  • alg: none. The spec defines "unsecured JWTs" whose signature part is empty (RFC 7519 section 6). A library that trusts the header will accept a token whose alg is none, so an attacker can edit the payload freely. Reject it.
  • Algorithm confusion. A server expects RS256 and holds the RSA public key. An attacker sets alg: HS256 and signs with that public key as if it were an HMAC secret. A verifier that picks the algorithm from the header then verifies it successfully. The public key is public, so the attacker holds it.
  • The fix for both is the same: the verifier pins the algorithm (and the key type) in its own configuration and never lets the token choose (RFC 8725 section 3.1).
  • Claims are not optional. A valid signature on a token meant for another API is still the wrong token: check iss, aud and exp (and nbf if present) every time (RFC 8725 sections 3.8 to 3.9). Allow a small clock-skew tolerance, seconds not minutes, because issuer and verifier clocks differ.

Revocation, short lifetimes and refresh tokens​

A signed token cannot be recalled, so keep access tokens short-lived (minutes) and pair them with a refresh token that the authorization server looks up in a store. Revoking means deleting the refresh token; the damage window is then at most the remaining access-token lifetime. For public clients, RFC 9700 requires that refresh tokens be either sender-constrained or rotated on every use, so a stolen one is detected when both the thief and the user try to use it. If instant revocation matters, use opaque access tokens checked by introspection (RFC 7662) and revoke them with RFC 7009; you trade a network call per request for immediacy.

Where a single-page app keeps its tokens​

The storage trade-off (XSS reads localStorage, CSRF rides a cookie) is laid out in the cookies page. The IETF's current guidance for browser apps goes one step further: a small backend for frontend (BFF) performs the OAuth flow, keeps the tokens server-side and gives the browser only an HttpOnly session cookie (OAuth 2.0 for Browser-Based Applications, an Internet-Draft). The browser never holds an access token at all.

Redirect URI validation and state​

The authorization server must compare the redirect_uri against pre-registered values by exact string match (RFC 9700 section 2.1). A prefix or wildcard match lets an attacker point the code at a page they control. state (or PKCE) binds the response to the request that started it, defeating forged callbacks.

OAuth versus OpenID Connect​

OAuth says the app may call this API. It does not say who the user is, and treating an access token as proof of login is a classic mistake. OpenID Connect (OIDC Core 1.0) adds the missing layer on top of the same code flow:

ID tokenAccess token
Meant forThe client app (its aud is the client id)The resource server (API)
SaysWho authenticated and when: iss, sub, aud, exp, iat, and a nonce echoing the requestWhat the holder may do, usually via scope
FormatAlways a JWTA JWT or an opaque string; the client should not parse it
Client shouldValidate signature, iss, aud, nonce, expiry, then log the user inSend it to the API; never inspect it to learn the user

OIDC also defines the openid scope that requests an ID token, a UserInfo endpoint for extra profile claims, and a discovery document at /.well-known/openid-configuration that lists the endpoints and the JWKS location. SAML 2.0 (an OASIS standard from 2005) solves the same single sign-on problem with XML assertions and is still common in enterprise SSO.

OAuth grants​

GrantUse forStatus
Authorization code + PKCEUsers logging in through browsers, mobile and desktop appsThe default; required for public clients
Client credentials (RFC 6749 section 4.4)Service to service, no user involvedCurrent; protect the secret or use mTLS client auth
Device authorization (RFC 8628)TVs and CLIs with no browser: the user enters a short code on another deviceCurrent
Implicit (token in the URL fragment)Was for single-page appsRFC 9700 says clients SHOULD NOT use it; the OAuth 2.1 draft drops it
Resource owner password credentialsWas for trusted first-party appsRFC 9700 says it MUST NOT be used; the OAuth 2.1 draft drops it

Credential types compared​

CredentialState livesRevocationSizeBest use
Server session idServer storeDelete the entry; immediateTens of bytesFirst-party browser apps
Self-contained JWTIn the tokenNot before exp without a storeGrows with claimsShort-lived access tokens verified by many services
Opaque access tokenAuthorization serverRevoke at the server; resource servers introspectTens of bytesWhen immediate revocation matters
API keyProvider's databaseDelete or rotate the keyA fixed stringIdentifying a calling project; not strong user authentication
mTLS client certificateIssuing CA and the certificate's validityShort certificate lifetimes or a revocation checkA certificate chain per connectionService to service, device identity

An API key identifies the caller, and it is long-lived and often copy-pasted into config, logs and URLs. RFC 6750 section 2.3 discourages sending a bearer credential in the URL query string because URLs are logged by servers, proxies and browsers. Send keys in a header, scope them narrowly and rotate them.

Service-to-service identity: mTLS and signed tokens​

Plain HTTPS authenticates only the server. Mutual TLS makes the client present a certificate too. The handshake difference is small, and the full handshake lives on TLS:

ClientServerClientHelloServerHello{CertificateRequest}{Certificate, CertificateVerify, Finished}{Certificate}{CertificateVerify}{Finished}mTLS onlymTLS onlymTLS only
A TLS 1.3 handshake with the mTLS-only messages drawn thicker and tagged. Braces mean the message is encrypted. EncryptedExtensions is omitted for clarity. The server asks for a certificate; the client answers with its certificate and a signature proving it holds the private key.

The server validates the client certificate against a CA it trusts and reads an identity from it, usually the Subject Alternative Name; service meshes use a SPIFFE ID there (SPIFFE concepts). Certificate chains, issuance and expiry belong to certificates and PKI. OAuth can reuse the same machinery: RFC 8705 lets a client authenticate to the token endpoint with a certificate and binds an access token to the certificate, so a stolen token is useless without the private key. DPoP (RFC 9449) gives the same sender-constraining at the application layer without needing client certificates.

The trade-off between mTLS and a signed token for services: mTLS proves identity per connection and needs certificate rotation machinery; a signed token proves identity per request and survives a proxy that terminates TLS, which is why gateways often convert one into the other (proxies and gateways). For the client-credentials grant, the secret in step 6 should be replaced by mTLS or a signed assertion where possible.

Multi-factor authentication​

  • TOTP (RFC 6238) is HOTP (RFC 4226) with the counter replaced by time: counter = floor(unix_time / 30), then HMAC-SHA1(secret, counter) is dynamically truncated to a number and reduced to 6 or 8 digits. RFC 6238 appendix B gives a test vector: the ASCII secret 12345678901234567890 at time 59 s gives counter 1 and the 8-digit code 94287082, so a 6-digit app would show 287082. The server accepts the current window and often one neighbour to absorb clock drift.
  • SMS codes are weaker: they can be redirected by SIM swapping and read by malware. NIST SP 800-63B classifies SMS and voice delivery as a restricted authenticator (NIST SP 800-63B).
  • TOTP and push approvals are phishable: a fake site can relay the code to the real one within the 30-second window. The only widely deployed factor that resists that is the next one.

Passkeys and WebAuthn​

WebAuthn (W3C Web Authentication) replaces the shared secret with a public-key pair per site. At registration the authenticator (a phone, a security key, the OS) creates a key pair and the site stores only the public key. At login the site sends a random challenge; the authenticator signs it together with data that includes the site's relying-party ID and the origin the browser reports. The server verifies the signature with the stored public key.

Two properties make this phishing-resistant. First, the browser fills in the origin, not the user: a look-alike site on a different origin gets a different relying-party ID, so the authenticator does not even offer that credential, and a signature made for the real site cannot be replayed elsewhere. Second, no secret is transmitted, so a database leak at the site yields only public keys. Passkeys (FIDO Alliance) are WebAuthn credentials that can be synced across a user's devices by the platform, so losing one phone does not lock the user out; the price is that account recovery and the sync provider become part of your threat model.

Login and password-reset design​

Textbook vs real world​

The textbook saysProduction doesWhy
JWTs are stateless, so they scaleShort access-token lifetimes plus a server-side refresh-token store, sometimes a deny-listStateless verification is cheap, but revoking a stolen or fired-user token needs state again
OAuth is how you log inOpenID Connect for login, OAuth for API accessAn access token says what an app may call, not who the user is; the ID token carries identity
HTTPS is enough to protect an API keyKeys in headers, scoped, rotated and scanned for in repos and logsTLS protects the wire; the key still leaks through logs, URLs, config files and client code
Add a one-time code over SMS and you have two factorsAuthenticator apps, security keys and passkeys; SMS as a fallback at mostSMS codes fall to SIM swaps and phishing relays; passkeys are bound to the origin
Store the token in localStorage for a single-page appA backend-for-frontend holding tokens behind an HttpOnly session cookieAny XSS can read localStorage; the BFF keeps the token out of script's reach

In production​

  • Google documents its sign-in as OpenID Connect: a discovery document, signed ID tokens and a published key set (Google: OpenID Connect).
  • GitHub offers fine-grained personal access tokens limited to chosen repositories and permissions, with an expiry (GitHub docs: managing personal access tokens).
  • Stripe issues restricted API keys that grant only selected resource permissions, so a leaked key reaches less (Stripe docs: API keys).
  • AWS signs every API request with Signature Version 4: a key derived from the secret key via HMAC signs the request, and the secret key itself never travels (AWS: Signature Version 4).
  • Kubernetes issues pods bound service-account tokens that are time-limited and audience-bound (Kubernetes: service accounts).
  • A breach, and what it teaches. In October 2023 Okta disclosed that an attacker had accessed its customer-support case system, and warned that HAR files customers upload for troubleshooting can contain session tokens that an attacker can use to impersonate users (Okta: tracking unauthorized access to Okta's support system). Cloudflare described how the attacker used a session token hijacked from a support ticket created by one of its employees to reach its Okta instance (Cloudflare blog). The lesson is the bearer-token rule: a session token pasted into a ticket or log is the account.

History and evolution​

HISTORY
  • 1994 Cookies appear in Netscape, giving stateless HTTP a way to remember a login (history on the cookies page).
  • 1999 HTTP Basic and Digest authentication are standardised in RFC 2617 (now RFC 7617 and RFC 7616). Basic sends the password on every request, so it is only safe over TLS.
  • 2007 OAuth 1.0 is written by a community because sites needed delegated API access without handing out passwords. It becomes RFC 5849 in 2010; its request-signing scheme was hard to implement correctly.
  • 2012 RFC 6749 and RFC 6750 replace signing with bearer tokens carried over TLS, which is simpler and moves the risk to token theft.
  • 2014 OpenID Connect Core 1.0 is finalised because OAuth alone did not say who the user was.
  • 2015 RFC 7519 standardises the JWT. RFC 7636 adds PKCE after authorization-code interception attacks on mobile apps.
  • 2019 WebAuthn Level 1 becomes a W3C Recommendation; Level 2 follows in 2021 (W3C Web Authentication).
  • 2020 RFC 8705 (mTLS-bound tokens) and RFC 8725 (JWT best practices, written after alg-confusion and none bugs) are published.
  • 2022 Apple, Google and Microsoft announce support for synced passkeys (Apple newsroom: joint announcement, May 2022).
  • 2023 RFC 9449 standardises DPoP, sender-constraining tokens without client certificates.
  • 2025 RFC 9700, the OAuth 2.0 security best current practice, tells implementers to retire the implicit and password grants and to require exact redirect-URI matching.
  • Now Passkeys are available on the major platforms; track adoption through the FIDO Alliance. OAuth 2.1 is still an Internet-Draft that consolidates these practices (draft-ietf-oauth-v2-1).
  • Next Passkeys as the default login, and sender-constrained tokens (DPoP, mTLS) so that a stolen token alone is not enough.

Hands-on​

Real runs on macOS with OpenSSL 4.0.3 (Homebrew) and curl 8.7.1, abbreviated. The system /usr/bin/openssl on macOS is LibreSSL and some options differ. Never paste a production token into an online decoder; decode locally.

Decode a JWT payload. base64url uses - and _ and drops the = padding, so restore both before base64 -d:

T=eyJhbGciOiJIUzI1NiIs... # the token from the worked example
echo "$T" | cut -d. -f2 | awk '{ while (length($0) % 4) $0 = $0 "="; print }' | tr '_-' '/+' | base64 -d
{"iss":"https://auth.example.com","sub":"user-42","aud":"api.example.com","exp":1893456000}

Without the padding fix, base64 -d can drop the end of the JSON. Anyone can do this, which is the point: the payload is not secret.

Recompute the HS256 signature and compare it with the third part of the token:

printf '%s' "$(echo "$T" | cut -d. -f1,2)" | openssl dgst -sha256 -hmac demo-secret -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
JAHZ8QMBxvQWZPeZbkbcKN-9ofEzIiQ8pbg6PTPQbew

It matches the token's third part exactly. Change one character of the payload and it will not.

See the credential headers curl sends. These ran against a local test server:

curl -v -u ada:secret http://127.0.0.1:8765/ 2>&1 | grep -E '^[<>] (GET|Authorization)'
curl -v -H "Authorization: Bearer $T" http://127.0.0.1:8765/ 2>&1 | grep -E '^[<>] (GET|Authorization)'
> GET / HTTP/1.1
> Authorization: Basic YWRhOnNlY3JldA==
> GET / HTTP/1.1
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiO...

YWRhOnNlY3JldA== is just base64("ada:secret"): Basic is the password in the open, so only use it over TLS.

mTLS with openssl s_server and s_client. The server demands a client certificate (-Verify 1) and trusts one CA file; the test certificates are self-signed with a SPIFFE-style URI in the client's SAN:

openssl s_server -accept 8444 -cert srv.pem -key srv.key -Verify 1 -CAfile cli.pem -tls1_3
openssl s_client -connect localhost:8444 -CAfile srv.pem -cert cli.pem -key cli.key -tls1_3

With the client certificate, the server log shows the identity it extracted. Without it:

verify depth is 1, must return a certificate
depth=0 CN=svc-billing
verify return:1
...
error:0A0000C7:SSL routines:tls_process_client_certificate:peer did not return a certificate

The first two lines are the server verifying the client certificate presented by the first connection; the error is the second connection, which sent none. In TLS 1.3 the client may finish its side of the handshake before the server's rejection arrives, so watch the server's log, not just the client's Verify return code.

Compute a TOTP code by hand with the RFC 6238 test secret, using Node:

node -e 'const c=require("crypto");const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(Math.floor(59/30)));const m=c.createHmac("sha1","12345678901234567890").update(b).digest();const o=m[19]&15;console.log(String((m.readUInt32BE(o)&0x7fffffff)%1e8).padStart(8,"0"))'
94287082

That is the value RFC 6238 appendix B lists for time 59.

Gotchas​

GOTCHAS6
JWT payloads are secret
Logging out invalidates a JWT
OAuth is a login protocol
Trust the algorithm the token declares
401 and 403 are interchangeable
Any token in localStorage is fine if the site has HTTPS

Interview questions​

INTERVIEWWhat is the difference between authentication and authorization, and how do 401 and 403 map to them?junior
Show answer

Authentication proves who you are; authorization decides what you may do once identified. A request with no valid credentials gets 401 and a WWW-Authenticate header; a request from a known identity that lacks permission gets 403. Authentication comes first, and authorization is checked on every action.

Follow-ups:

  • When would you return 404 instead of 403? To avoid revealing that a resource exists to someone who may not know about it.
  • Is a failed login a 401 or a 403? A 401 for bad credentials; many APIs use 403 only after the identity is established.
INTERVIEWSessions versus JWTs: what are the trade-offs?mid
Show answer

A session stores the facts on the server and gives the client an opaque id, so revocation is one delete and the cookie is tiny, but every request needs a store lookup and a multi-server deployment needs a shared store. A JWT carries the facts in a signed token, so any service with the key verifies it without a lookup, but you cannot revoke it before exp, it grows with its claims, and stale claims persist. I default to sessions for a first-party web app and use short-lived JWTs between services.

Follow-ups:

  • How do you revoke a JWT? Keep it short-lived and revoke the refresh token, or keep a deny-list; both are state.
  • How big can a JWT get? It rides in every request, so large claim sets cost bandwidth and can hit header and cookie size limits (cookies page).
  • Which scales better? Verification scales either way; the session store is the part you must make shared and fast.
INTERVIEWHow do you validate a JWT correctly?mid
Show answer

Parse it, take the algorithm from my own configuration (never from the header), pick the key by kid from the issuer's JWKS, verify the signature, then check iss, aud, exp and nbf against what I expect, allowing a few seconds of clock skew. Any failure is a 401. The checklist is RFC 8725.

Follow-ups:

  • What is alg=none? An unsecured JWT with an empty signature; a verifier trusting the header would accept forged tokens.
  • What is algorithm confusion? Signing an HS256 token with an RS256 public key; pinning the algorithm stops it.
  • What if the issuer rotates keys? Verifiers re-fetch the JWKS when they see an unknown kid, with caching and a rate limit on the refresh.
INTERVIEWExplain the OAuth 2.0 authorization code flow and why PKCE exists.mid
Show answer

The app redirects the user to the authorization server with a client_id, redirect_uri, scope, state and a PKCE code_challenge. The user logs in and consents there, so the app never sees the password. The server redirects back to the registered redirect_uri with a short-lived, single-use code. The app then calls the token endpoint directly and swaps the code plus the code_verifier for an access token. PKCE exists because the code travels through the browser, where another app can intercept it; without the verifier, a stolen code is useless.

Follow-ups:

  • What is redirect_uri validation for? Exact matching against registered values stops an attacker from redirecting the code to their own site.
  • What is state for? It ties the callback to the request that started it, defeating forged responses.
  • Do confidential clients need PKCE? The OAuth 2.1 draft says yes for all clients; RFC 9700 requires it at least for public clients.
INTERVIEWWhat is the difference between OAuth and OpenID Connect, and between an ID token and an access token?mid
Show answer

OAuth 2.0 delegates access to an API. OpenID Connect adds authentication on top of the same flow by returning an ID token, a JWT that says who the user is and is meant for the client app. The access token is meant for the resource server and says what the holder may call; the client treats it as opaque and never uses it to find out who the user is.

Follow-ups:

  • What is the nonce for? It binds the ID token to the original request, so a captured token cannot be replayed into another login.
  • Where do I get more user data? From the UserInfo endpoint, using the access token.
INTERVIEWWhere should a single-page app store its tokens, and why?mid
Show answer

Not in localStorage: any XSS can read it. The strongest design is a backend for frontend that runs the OAuth flow and keeps the tokens server-side, giving the browser only an HttpOnly, Secure, SameSite session cookie. Then XSS can still make requests while the page is open but cannot steal a token, and the cookie is protected from CSRF with SameSite and a CSRF token (cookies page).

Follow-ups:

  • Is an HttpOnly cookie immune to XSS? No: injected script can still send requests that carry it. It only prevents theft.
  • What does CSRF change about the choice? Cookies are attached automatically, so they need SameSite and a token; an Authorization header is not attached by the browser.
INTERVIEWHow would you authenticate service-to-service calls?senior
Show answer

Give each workload a short-lived identity and verify it on every call. With mTLS, a mesh or a platform issues each workload a certificate whose SAN carries its identity (for example a SPIFFE ID), and both sides authenticate at connection time. With tokens, a service obtains an access token via the client-credentials grant, ideally authenticated by a certificate or a signed assertion, and the callee validates it. Either way I keep lifetimes short, automate rotation, and still do authorization per call, because identity is not permission.

Follow-ups:

  • mTLS or signed tokens? mTLS authenticates the connection and needs certificate rotation; tokens authenticate each request and survive TLS-terminating proxies. Many systems use both.
  • How do I rotate? Short certificate lifetimes plus an automated issuer, so rotation is routine, not an incident.
  • What is SPIFFE? A standard for workload identities (SPIFFE IDs) carried in X.509 or JWT documents (SPIFFE concepts).
INTERVIEWDesign a login flow with MFA and a safe password reset.senior
Show answer

Accept the password over TLS, check it against an Argon2id hash, and respond identically for unknown accounts to prevent enumeration, with rate limits per account and per source. Then require a second factor: a passkey or a TOTP code, with SMS only as a fallback. On success, issue a new session id (preventing fixation) in an HttpOnly, Secure, SameSite cookie. Password reset sends a long, random, single-use, short-lived token that I store hashed, answers the same for unknown addresses, and ends all other sessions on completion.

Follow-ups:

  • How do you stop credential stuffing? Per-source and per-account limits, breached-password checks and MFA; see rate limiting.
  • What about account recovery for passkeys? It becomes the weakest link, so it needs a strong, audited path.
  • Why is the reset token stored hashed? So a database leak does not hand out working reset links.
INTERVIEWWhy are passkeys phishing-resistant?senior
Show answer

A passkey is a public-key pair created per site. At login the authenticator signs a server challenge together with the site's relying-party ID and the origin as the browser reports it. A phishing site has a different origin, so the authenticator does not offer the credential, and a signature made for the real site is not valid for any other. Also nothing secret is sent, so a server breach leaks only public keys.

Follow-ups:

  • What if the phishing site proxies the real one? The origin the browser reports is still the proxy's, so the signature does not verify for the real site.
  • What do synced passkeys trade away? Convenience and recovery improve, but the sync provider and recovery path join the trust boundary.

Fun facts​

FACTSTrivia worth knowing
  • A TOTP code is just HMAC-SHA1 over a 30-second counter, truncated to 6 digits. Nothing secret is stored on the phone but the shared key (RFC 6238).
  • RFC 7636 says PKCE is pronounced "pixy".
  • The RFC 6238 test secret is the ASCII string 12345678901234567890; every TOTP library is tested against it.
  • A passkey never transmits a secret: the server only ever stores a public key.
  • The "none" algorithm in JWT is not a bug but a feature of the spec (unsecured JWTs); the bug is verifiers that accept it from untrusted input.

Cheat sheet​

NUMBERSNumbers to remember
  • 401 / 403 - unauthenticated / unauthorised (RFC 9110)
  • 3 parts, 2 dots - JWT header.payload.signature
  • 32 / 256 / 64 bytes - HS256 / RS256 (2048-bit) / ES256 signature sizes, or 43 / 342 / 86 base64url characters
  • 43 to 128 characters - PKCE code_verifier length (RFC 7636)
  • 10 minutes - recommended maximum authorization-code lifetime (RFC 6749 section 4.1.2)
  • 30 seconds - default TOTP time step (RFC 6238)
  • Argon2id 19 MiB, 2 iterations, 1 lane - OWASP minimum password-hash configuration
DECISIONWhich credential for which job?
  • First-party web app, one backend - server session in an HttpOnly cookie.
  • Single-page app calling an API - code flow with PKCE, ideally behind a backend for frontend.
  • Many services verifying the same caller - short-lived JWT signed with RS256 or ES256, published via JWKS.
  • Service to service - mTLS or client-credentials with a certificate-bound or signed token.
  • Humans - passkeys first, TOTP second, SMS last.