03 / 05
Security / 03

Authentication primitives

Authentication is the question "who is this request from". Every primitive on this page is a different answer to it — a password proves "you know a shared secret", a session cookie proves "the server recognised you before", a JWT proves "an authority asserted you are this subject", mTLS proves "you hold the private key for this certificate", a passkey proves "you control this physical authenticator". Mistaking what a primitive proves is the root cause of most authentication bugs. This chapter walks through each one — what it asserts, where it goes wrong in practice, and how the pieces compose into the sign-in flows that actually ship.


Authentication vs authorization, the cleanup

Authentication is "who are you". Authorization is "are you allowed to do this". The two are constantly conflated, especially in code that uses "auth" for both. The cost of conflating them is that authorization bugs hide inside authentication systems, and authentication bugs hide inside authorization systems. A single sentence to keep them separate: the auth-N system tells the auth-Z system who the request is from; the auth-Z system decides whether that identity may do what the request asks.

Almost every primitive in this chapter is an authentication primitive — it asserts an identity. The authorization layer sits on top: given an authenticated user, which rows can they read, which actions can they take, which roles are they in. The two are independent concerns; mixing them creates IDOR (Insecure Direct Object Reference) bugs and privilege escalations.

Passwords — still the floor, still hard

A password proves "the user knows a shared secret". That sentence has more failure modes than any other primitive on this page. Three rules that catch most of them:

1. Use a slow, memory-hard, salted hash. bcrypt is the safe default; Argon2id and scrypt are the modern alternatives with explicit memory-hardness parameters. Never use MD5, SHA-1, or unsalted SHA-256 — modern GPUs can compute billions of those per second, so a leaked database becomes plaintext within hours. Tune the work factor so a single hash takes ~100ms on your hardware; that throttles brute-force without driving login latency through the floor.

# Python with passlib
from passlib.hash import argon2

hashed = argon2.hash("hunter2")
# $argon2id$v=19$m=65536,t=3,p=4$...$...

argon2.verify("hunter2", hashed)  # True
argon2.needs_update(hashed)       # bump work factor as hardware improves

2. Check the password against a breach corpus. Have I Been Pwned's password API (k-anonymous, no full hashes leave your server) lets you reject the 800 million passwords already known to attackers. If a user's "secure" password is in there, no amount of bcrypt rounds saves them from credential stuffing.

3. Rate-limit login attempts per identity and per IP. Without it, an attacker can try every common password against every email in a leaked list. Per-identity limit catches targeted attacks; per-IP catches credential-stuffing rings. Neither is sufficient alone.

What about password rotation policies? NIST 800-63B retired forced rotation in 2017. Forced 90-day rotations push users toward predictable patterns (Spring2026!, Summer2026!) and reduce security overall. Rotate only on suspicion of compromise. Same guidance retired complexity rules ("one uppercase, one number, one symbol") because they encourage exactly the worst passwords and discourage long passphrases.

Sessions — the original web auth

A session is a server-side record of "this client identifier has been authenticated as user N until time T". The client holds an opaque cookie; every request sends the cookie; the server looks up the session in storage. Simple, well-understood, holds up.

Three properties of session cookies that matter:

HttpOnly means JavaScript cannot read the cookie. Defeats most XSS-based session theft. Always set it.

Secure means the cookie is only sent over HTTPS. Defeats passive network capture. Always set it; the cost in plain-HTTP environments is that you need HTTPS, which you should have anyway.

SameSite controls when the cookie is sent on cross-site requests. SameSite=Lax is the modern default and defeats most CSRF; SameSite=Strict breaks legitimate cross-site links to your app (incoming from email, etc.). Use Lax unless you have a specific reason for Strict.

Set-Cookie: session=abc123;
  Path=/;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Max-Age=86400;
  Domain=app.example.com

Session storage is usually a hot KV store (Redis, Memcached) keyed by the session id. Logout deletes the entry; password change deletes all entries for the user; "log me out everywhere" iterates and deletes by user id. The deletion ability is the underrated strength of sessions over JWTs.

JWT — bearer tokens with structure

A JWT (JSON Web Token) is a base64-encoded JSON object with a cryptographic signature. It says "this issuer asserts that subject X has claims Y, valid until time T". The server verifies the signature, reads the claims, treats the token as proof of the user's identity without consulting a session store.

# Three base64-encoded parts separated by dots
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE4MDB9.SflKxw...

# Header  { "alg": "RS256", "typ": "JWT" }
# Payload { "sub": "123", "exp": 1800, "iss": "auth.example", "aud": "api" }
# Signature  RSA-SHA256(header.payload, issuer's private key)

JWTs shine when verification needs to be cheap and not depend on a session lookup — service-to-service auth, stateless APIs, multi-region setups where every region can verify tokens locally with the issuer's public key. The cost is that they are almost impossible to revoke before expiry; once issued, a JWT is valid until exp, and the only mitigation is short expiry windows plus a refresh-token flow.

Three JWT mistakes that account for most of the CVEs:

Trusting the alg field. A JWT with "alg": "none" has no signature. A JWT signed with HS256 (HMAC) can be verified with any string as the key. If your code accepts jwt.decode(token, key=public_key) without pinning the algorithm, an attacker can re-sign with HS256 using the public key as the HMAC secret, and the library will happily verify it. Pin the algorithm in the verification call.

Not checking aud and iss. A JWT issued for service A may be replayed against service B if both trust the same issuer. The audience claim says who the token was issued for; verify it matches your service.

Long-lived tokens with no revocation. An access token valid for 24 hours that cannot be revoked means a leaked token is good for 24 hours. The fix is short access tokens (5-15 minutes) plus refresh tokens that can be revoked from a central store.

JWT in a cookie is fine. JWT in localStorage is dangerous (XSS can read it). The "stateless server" appeal of JWTs is mostly orthogonal to where the token lives; a cookie-stored JWT keeps you safe from XSS without giving up the JWT shape.

OIDC — the protocol that runs sign-in

OpenID Connect is an authentication layer on top of OAuth 2.0. The flow:

1. Your app redirects the user to the identity provider (Google, Okta, Auth0, your own IdP) with a list of requested scopes and a callback URL.

2. The user signs in with the IdP (which uses whatever primitive it chooses — password, passkey, MFA).

3. The IdP redirects back to your callback with an authorization code.

4. Your app exchanges the code (server-side, with a client secret or PKCE verifier) for an ID token and an access token.

5. The ID token is a JWT asserting "this is the user". Your app verifies the signature using the IdP's published public keys, reads the claims (sub, email, name), and creates a local session for the user.

OIDC is what stands behind "Sign in with Google", "Sign in with Apple", every enterprise SSO integration. The wins over implementing your own auth: you do not store passwords, you do not handle MFA, you do not run the password-reset flow, and you outsource the security-of-credentials problem to an organization that probably has a security team.

The PKCE (Proof Key for Code Exchange) extension is required for public clients (mobile apps, SPAs) and recommended for everyone. The app generates a random secret (the verifier), hashes it (the challenge), sends the challenge with the initial redirect, and sends the verifier with the code exchange. Defeats authorization-code interception attacks where an attacker intercepts the code but not the verifier.

Authorization URL:
  https://idp.example/authorize?
    client_id=app
    &redirect_uri=https://your.app/callback
    &response_type=code
    &scope=openid profile email
    &code_challenge=base64(SHA256(verifier))
    &code_challenge_method=S256
    &state=random_csrf_token

Code exchange:
  POST https://idp.example/token
    grant_type=authorization_code
    &code=...
    &redirect_uri=...
    &code_verifier=original_verifier

Response:
  { "id_token": "eyJ...", "access_token": "eyJ...", "refresh_token": "..." }

mTLS — when both sides prove they are who they say

Mutual TLS extends the ordinary TLS handshake. Ordinary TLS proves the server is who its certificate says; mTLS adds the symmetric step where the client presents a certificate too and the server verifies it. The result: both endpoints are cryptographically authenticated to each other, no passwords or tokens involved.

mTLS is the default authentication in service meshes (Istio, Linkerd) and is increasingly common for high-security APIs (financial services, healthcare partners). Each service gets a certificate from an internal CA; the certificate's subject identifies the service; peer services verify the certificate chain on every connection.

The win: identity is at the connection layer, not the application layer. The application does not have to parse tokens, validate signatures, or trust headers — the proxy (Envoy, in most cases) terminates mTLS and passes the authenticated identity to the app via a header it controls.

The cost: certificate lifecycle. Every service needs a certificate; certificates expire; rotation is non-trivial. SPIFFE / SPIRE solve this by issuing short-lived (one hour) certificates to workloads automatically, with the renewal driven by the SPIRE agent on each node. Without that infrastructure, mTLS becomes a quarterly outage as certificates silently expire.

User-facing mTLS (client certificates in the browser) exists but is awkward. The browser UX is terrible, certificate installation is a process most users cannot follow, and the lockout when a certificate is missing is harsher than a password reset. Government and enterprise PKI deployments use it; consumer apps do not.

Passkeys / WebAuthn — the modern replacement for passwords

A passkey is a public/private keypair stored on the user's device (typically synced across the user's devices via iCloud Keychain, Google Password Manager, or 1Password). Sign-in is: the server sends a challenge, the device signs it with the private key, the server verifies with the previously-registered public key. The user authenticates to their device (Face ID, Touch ID, Windows Hello, PIN) to unlock the key for signing.

What makes passkeys the right answer where they fit:

No shared secret. The server holds only the public key; a server breach cannot impersonate users.

Phishing-resistant. The browser binds the challenge to the origin (example.com). A phishing site at examp1e.com cannot get a valid signature because the browser refuses to sign for the wrong origin. This is the property that makes passkeys fundamentally stronger than even MFA-protected passwords.

Built into the platform. Safari, Chrome, Firefox, Edge all support WebAuthn. iCloud Keychain and Google Password Manager sync passkeys across the user's devices automatically; 1Password and Dashlane have parity.

The state of passkeys in 2026: every major consumer service (Google, Apple, Amazon, GitHub, Microsoft, Slack, Cloudflare) supports them. Enterprise adoption is uneven — many SaaS apps still treat passkeys as a "second factor" rather than a primary credential, which misses the point. The right deployment is passkey-as-primary with password as the legacy fallback for accounts that have not enrolled yet.

// Browser side, simplified
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: serverChallenge,           // from your /register endpoint
    rp: { name: "Example", id: "example.com" },
    user: { id: userIdBytes, name: "user@example.com", displayName: "User" },
    pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
    authenticatorSelection: { userVerification: "required" }
  }
});

// Server stores: credential.id, credential.response.publicKey

// Sign-in:
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: serverChallenge,
    allowCredentials: [{ id: credentialId, type: "public-key" }],
    userVerification: "required"
  }
});
// Server verifies assertion signature with stored public key

Multi-factor authentication — the layering

MFA pairs two or more authentication factors from different categories: something you know (password), something you have (phone, hardware key), something you are (biometric). The win is that compromising one factor is no longer enough; an attacker who phished the password still needs the second factor.

Three common second factors, ranked by what they actually stop:

TOTP (Google Authenticator, Authy, 1Password). Time-based 6-digit code from an app. Defeats remote credential stuffing. Vulnerable to real-time phishing — an attacker who proxies your login can capture the TOTP and use it within its 30-second window. Still much better than no MFA.

SMS / phone-based. Worst of the common options. SIM-swap attacks transfer the phone number to an attacker's SIM, defeating SMS-based MFA entirely. NIST 800-63B deprecated SMS as a high-assurance second factor in 2016; it is still better than nothing but should not be the only option offered.

Hardware keys (YubiKey, Titan). FIDO2/WebAuthn over USB or NFC. Phishing- resistant for the same reason passkeys are — the key signs only for the correct origin. The strongest of the common options.

Passkeys (as primary) and hardware keys (as second factor) overlap heavily — both are origin-bound WebAuthn. The distinction in deployment is mostly UX: passkeys sync across devices automatically, hardware keys are a physical thing you carry. For high-value accounts (admin consoles, financial accounts) hardware keys are still the gold standard.

Sign-in flows — how the pieces compose

Four common shapes for a working sign-in:

Password + session. User submits password to your server, server verifies against the bcrypt hash, creates a session, sends a cookie. The 1995-vintage flow that still works. Pair with bcrypt + breach-check + rate-limit + Lax cookie + HttpOnly + Secure and you are mostly fine for low-to-medium-stakes apps.

OIDC + session. Redirect to IdP, get back an ID token, verify, create a local session. The IdP handles password / MFA / passwords / passkey verification; your app handles only the post-auth session and authorization. The recommended default for any new app — outsource the credential problem.

OIDC + JWT access token. Same OIDC flow, but instead of a session you keep the access token and send it as a Bearer header on API requests. Suitable for SPAs and mobile apps that need to call APIs without an in-the-middle backend. Short token lifetime (5-15 min) plus refresh-token rotation is the rule.

Passkey-first. The user clicks "Sign in", the browser shows the passkey prompt, biometric unlocks the key, signature goes to the server, server creates a session. No password ever involved. Where the platform supports it and the user has a passkey, this is the smoothest UX and the strongest security on this page.

The right default for new apps in 2026: OIDC via a reputable IdP (Auth0, Clerk, Stytch, WorkOS, Cognito) for sign-in, with passkey enabled at the IdP level. Local session cookies for the post-auth state. You write zero password-handling code and inherit the IdP's MFA and passkey support.

Session lifecycle — login, logout, expiry, rotation

A working session has more states than "logged in or logged out". A short list of states and the transitions that matter:

Active session. Valid until inactivity timeout or absolute expiry. The cookie is sent on every request; the server validates against the store; access proceeds.

Idle expiry. Sessions that have not been used for N minutes (typically 30-60) expire. Forces re-authentication if the user walks away. Tunable per workload — banking apps use 5-15 minutes, marketing-content apps can go 24 hours.

Absolute expiry. Sessions older than M hours/days (typically 8 hours for web, longer for native) expire regardless of activity. Forces periodic re-auth even for active users. Limits damage from a session token that was silently exfiltrated.

Session rotation. On privilege escalation (logged-in user gains admin rights), the session id should be regenerated. Defeats session-fixation attacks where an attacker tricks the user into adopting a known session id, then escalates.

Forced revocation. "Sign out everywhere" lists and deletes all sessions for a user. Required on password reset, suspicious-activity flag, account compromise. This is where session-based auth is strictly better than JWT-based — for JWTs, the only equivalent is short token TTL plus a revocation list checked on every request.

Common mistakes — what the bug bounty reports show

Five patterns that show up in published auth CVEs and HackerOne reports more often than any others:

Authorization-by-obscurity in the token. The JWT includes "role": "user" and the app reads it directly. An attacker decodes the JWT, changes role to admin, re-signs with alg=none, and is admin. Fix: never trust JWT payload for authorization without verifying both the signature and the authority of the issuer to make that claim.

Reset tokens in URLs that get logged. Password-reset link is sent via email; user clicks; the token shows up in the web server access log, the referrer header on outbound clicks, the analytics script, and the browser history. Fix: short-lived one-time tokens (15 minutes), invalidated on first use, never used in plain HTTP, and cleared from history with a redirect after exchange.

Missing CSRF protection on state-changing endpoints. A logged-in user visits attacker.com; attacker.com submits a form to your-app.com/transfer-money; the browser sends the session cookie; the transfer goes through. Fix: SameSite=Lax cookies (modern default) plus, for high-stakes endpoints, a CSRF token bound to the session.

Account-enumeration via login or password-reset. The login endpoint says "that password is wrong" for known users and "that email does not exist" for unknowns. Attackers harvest valid emails from your user base. Fix: identical responses for both cases ("if that email is registered, you will receive a reset link").

OAuth redirect_uri validation that allows substring matches. The IdP allows redirect to https://your.app/* and an attacker registers https://your.app.evil.com/. The token is delivered to the attacker. Fix: exact-match redirect URI validation; never substring or wildcard.

Where authentication ends and authorization begins

Once you have an authenticated user, the authorization layer takes over. Three patterns worth knowing:

RBAC (role-based). Users have roles; roles grant permissions. Easy to explain, easy to audit, gets unwieldy when the role count explodes ("read-only-finance- eu-region-but-only-for-q4-reports").

ABAC (attribute-based). Decisions are made from attributes of the user, the resource, and the context. "Can read invoice if user.team == invoice.team and invoice.status != draft". More expressive; harder to audit. Implemented via OPA (Open Policy Agent), Cedar, or hand-rolled policy code.

ReBAC (relationship-based). Permissions follow graph relationships: "users in this group can read documents shared with this folder if they have access to any parent folder". Google's Zanzibar paper popularised this; OpenFGA and SpiceDB are open-source implementations. Fits collaboration apps where permissions inherit from organisational structure.

All three rely on the authentication layer correctly identifying the user. Bugs in authentication amplify into authorization disasters; do not skip the authentication- testing pass.

Further reading

The OWASP Authentication Cheat Sheet is the compact reference. NIST SP 800-63B (Digital Identity Guidelines, Authentication and Lifecycle Management) is the authoritative source and shorter than its reputation suggests. The OIDC spec at openid.net is well-written; read it once when you wire up your first integration. For passkeys, the passkeys.dev developer docs and the FIDO Alliance specifications. For mTLS at scale, the SPIFFE and SPIRE documentation covers workload identity beyond what reading the certificate spec teaches.

Inside this codex, the threat-modeling chapter covers how to find authentication threats in your design; the TLS material in the networking section covers the cryptographic foundations all of this depends on; the planned crypto-for-engineers chapter covers what makes a hash a hash and an AEAD an AEAD.

Found this useful?