Authentication Systems
Deep Dive

From first principles to production architecture. How tokens, sessions, and OAuth actually work under the hood — and when to build vs buy.

Deep Dive · March 2026 · ~25 min read

01 — Authentication vs Authorization

User credentials AUTHENTICATION "Who are you?" Identity verification identity AUTHORIZATION "What can you do?" Permission checking allowed Resource
Two separate gates. Authentication proves identity; authorization checks permissions. They happen in sequence but are independent concerns.

Authentication (AuthN) answers "who are you?" It verifies identity — checking a password, validating an OAuth token, or confirming a magic link. The output is a verified identity: "this is user #42."

Authorization (AuthZ) answers "what can you do?" Given a verified identity, it checks permissions — can user #42 edit this document? Access the admin panel? Delete this record?

Why separate them? Because they change independently. You might swap your login provider (AuthN) without touching your permission model (AuthZ). Or you might add role-based access control (AuthZ) without changing how users log in (AuthN). Coupling them means every auth change ripples across both systems.

In practice, most "auth systems" blend both. A JWT might carry both identity (sub: "user_42") and permissions (role: "admin"). But conceptually, they are distinct concerns that happen to travel together.


02 — The Token Lifecycle

Every auth system follows the same fundamental loop: prove identity once, then carry proof on every subsequent request. The "proof" is a token. Here's what happens from login to logout:

BROWSER SERVER DATABASE POST /login email + password Verify credentials bcrypt compare users.findOne() Issue tokens store refresh hash sessions.insert() Set-Cookie headers access_token (15m) refresh_token (7d) GET /api/data Cookie: access_token Validate token 200 + data Token expired! Cookie: refresh_token Rotate + reissue sessions.update() POST /logout Clear cookies + revoke sessions.delete() 1 2 3 4 5 6
Complete token lifecycle. Login issues tokens; every API call carries the access token; refresh replaces expired tokens; logout revokes everything.

Cookie Mechanics

Tokens travel via cookies. Four flags control their security:

FlagWhat it doesPrevents
HttpOnly JavaScript cannot read the cookie via document.cookie XSS token theft
Secure Cookie only sent over HTTPS Network sniffing
SameSite=Lax Cookie sent on top-level navigations but NOT cross-site POST/fetch CSRF attacks
Path=/ Cookie available to all routes under the path Accidental scope restriction
Set-Cookie: access_token=eyJhbGci...;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900

The golden rule: access tokens in HttpOnly cookies, never in localStorage. Any XSS vulnerability can read localStorage — but HttpOnly cookies are invisible to JavaScript. The browser sends them automatically; your code never touches them.


03 — Access Tokens vs Refresh Tokens

The single most common question: why two tokens instead of one?

The Core Tradeoff: DB Lookups vs Revocation Speed Check DB every request Never check DB Sweet spot Option A: One long-lived token Stateless JWT, expires in 7 days Fast (no DB hit) but cannot revoke Stolen token = 7 days of access Option B: Access + Refresh Access: 15 min, stateless, every request Refresh: 7 days, DB-checked, rare use Stolen access token = 15 min max The Analogy Access token = movie ticket (use it at the door, no questions asked, expires after the show) Refresh token = membership card (present at the box office to get a new ticket)
Two tokens solve the tradeoff between performance (no DB lookup per request) and security (ability to revoke quickly).

The Real Reason for Two Tokens

It comes down to one tradeoff: how often do you hit the database?

This means 99% of requests never touch the session database, but you can still revoke access within 15 minutes by deleting the refresh token. The access token will naturally expire, and when the client tries to refresh, it gets rejected.

PropertyAccess TokenRefresh Token
Lifetime5–15 minutes7–30 days
Where it's sentEvery API requestOnly to /refresh endpoint
Validated howSignature check (no DB)Hash lookup in DB
RevocableNo (wait for expiry)Yes (delete from DB)
Stored server-sideNoYes (as SHA-256 hash)
If stolen15 min of damage maxFull access until detected

Key insight: The access token is a cache of the authorization decision. It lets you skip the database for a short window. The refresh token is the real session. Revocation works by killing the refresh token, then waiting for the cached access token to naturally expire.


04 — JWT Deep Dive

A JSON Web Token is three Base64URL-encoded segments separated by dots. Nothing is encrypted — anyone can read the payload. The signature just proves it hasn't been tampered with.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIiw....SflKxwRJSM6poFDd... HEADER { "alg": "HS256", "typ": "JWT" } PAYLOAD (Claims) { "sub": "user_42", "role": "teacher", "iat": 1711234567, "exp": 1711235467 } SIGNATURE HMAC-SHA256( base64(header) + "." + base64(payload), secret ) Verification: recompute HMAC(header.payload, secret) and compare to the signature If they match, the token hasn't been tampered with and was issued by someone with the secret
JWT = Header.Payload.Signature. The payload is readable by anyone; the signature proves authenticity.

Signing: HMAC vs RSA

HS256 HMAC (Symmetric)

  • Same secret signs and verifies
  • Simpler, faster
  • Both parties must share the secret
  • Good for: monolith, single-service

RS256 RSA (Asymmetric)

  • Private key signs, public key verifies
  • Verifiers don't need the secret
  • Good for: microservices, third parties
  • Auth server signs; any service can verify

Why JWTs Are Stateless

The server doesn't store JWTs anywhere. All the information needed to validate one (the signing secret, the algorithm) is already on the server. This means:

Common JWT Pitfalls

PitfallWhat goes wrongPrevention
alg: "none" Attacker removes signature, sets algorithm to "none"; naive library accepts it as valid Always specify allowed algorithms explicitly; never trust the token's header
Weak secret HMAC secrets under 256 bits can be brute-forced Use crypto.randomBytes(32) minimum (256 bits)
Token bloat Stuffing permissions, user profile, etc. into payload; cookie size exceeds 4KB limit Keep payload minimal: sub, role, exp. Fetch details from DB when needed
No expiration Token without exp is valid forever Always set exp; reject tokens without it
Confusing encoding with encryption Developers assume Base64 = encrypted; put sensitive data in payload Never put secrets, passwords, or PII in JWT payload

05 — Opaque Tokens & Server-Side Sessions

The opposite of JWTs. An opaque token is a random string with no readable content. All session state lives in the database. The token is just a lookup key.

1. Generate crypto.randomBytes(32) = "a7f3c9b1e8d2..." 2. Hash SHA-256(token) = "9e2d4f8a1b3c..." 3. Store Hash sessions.insertOne({ tokenHash: "9e2d..." }) 4. Send to Client Set-Cookie: "a7f3c9b1..." 5. Validation: receive token, compute SHA-256, look up hash in DB Client never sees the hash. DB never sees the raw token. Neither can impersonate the other. If DB is breached: Attacker gets hashes only. SHA-256 is one-way. Tokens cannot be reconstructed.
The hash-before-store pattern: the raw token goes to the client, only the SHA-256 hash goes to the database. A database breach exposes no usable tokens.

Opaque vs JWT Comparison

PropertyJWTOpaque Token
Contains data?Yes (claims in payload)No (just a random string)
DB lookup per requestNoYes (every request)
Revocable instantlyNo (needs blocklist)Yes (delete from DB)
Token size~800 bytes+~44 bytes (32 bytes Base64)
Breach impactToken is self-contained; usable as-isOnly hashes in DB; unusable
Horizontal scalingEasy (stateless)Needs shared session store

TTL Indexes for Cleanup

In MongoDB, create a TTL index on the expiresAt field. MongoDB automatically deletes expired documents within ~60 seconds of their expiry time:

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

This eliminates the need for a cron job or manual cleanup. Expired sessions vanish automatically.


06 — Session Management Patterns

Sliding Window vs Fixed Expiry

Fixed Expiry

  • Session expires at a set time regardless of activity
  • Login at 9:00 AM, expires at 9:15 AM. Period.
  • Simpler, more predictable
  • Users must re-login periodically
  • Use for: banking, healthcare, high-security apps

Sliding Window

  • Each request resets the expiry timer
  • Active at 9:14? New expiry: 9:29
  • Better UX for long sessions
  • Idle timeout still applies
  • Use for: SaaS apps, consumer products

Session Fixation

The attack: Attacker creates a session, tricks the victim into using that session ID (via a crafted link), then hijacks the session after the victim logs in.

The fix: Always regenerate the session ID after authentication. The old session ID (potentially known to the attacker) becomes useless. The newly authenticated session gets a fresh, unknown ID.

// After successful login:
const oldSessionId = req.cookies.session;
await sessions.delete(oldSessionId);  // kill old session

const newToken = crypto.randomBytes(32);  // fresh token
await sessions.create(newToken, userId);  // new session
res.cookie("session", newToken);          // replace cookie

Concurrent Session Limits

Decide your policy: how many active sessions per user?

Device-Aware Sessions

Store metadata alongside the session hash: user agent, IP, last active timestamp, device fingerprint. This enables:


07 — The Refresh Flow in Detail

CLIENT SERVER DATABASE GET /api (401) 1 POST /refresh refresh_token_v1 2 hash(refresh_token_v1) lookup hash sessions.findOne() Generate new pair access_token_v2 + refresh_token_v2 3 delete old hash insert new hash access_token_v2 refresh_token_v2 4 Retry GET /api access_token_v2 5 Valid! Return data Replay Detection: if refresh_token_v1 is used again after rotation... ...the server knows it was stolen. Revoke the ENTIRE token family. Force re-login.
Refresh token rotation: each refresh issues a new pair and invalidates the old refresh token. Reuse of an old token triggers full revocation.

Token Families and Replay Detection

Every refresh token belongs to a family (typically identified by the original session ID). When the server sees a refresh token that has already been used:

  1. It knows the token was stolen (either the attacker or the legitimate user has the old one)
  2. It revokes all tokens in that family — both the legitimate and stolen branches
  3. Both parties must re-authenticate

This is the "nuclear option" — but it's the only safe response because the server can't tell which party is legitimate.

The Race Condition Problem

Scenario: User has two browser tabs open. Both tabs' access tokens expire simultaneously. Both send refresh requests at the same time with the same refresh_token_v1.

What happens with naive rotation: Tab A refreshes first, gets v2, invalidates v1. Tab B arrives with v1 — it's already invalidated. Server thinks it's a replay attack. Revokes everything. User gets logged out.

Solutions:

1. Grace period: Allow the old refresh token for ~10 seconds after rotation

2. Deduplication: If the same refresh token arrives within a short window, return the same new pair

3. Client-side queue: Use a mutex/lock so only one tab refreshes at a time (via BroadcastChannel API)


08 — OAuth 2.0 & OpenID Connect

OAuth 2.0 is a delegation framework. It lets a third-party app access a user's data without seeing their password. OpenID Connect (OIDC) adds an identity layer on top.

Authorization Code Flow with PKCE Your App Auth Server (Google, GitHub, etc.) Resource 1 Generate code_verifier (random) code_challenge = SHA256(verifier) 2 Redirect: /authorize?code_challenge=...&state=... 3 User logs in 4 Redirect: /callback?code=AUTH_CODE&state=... 5 POST /token { code, code_verifier } 6 Verify: SHA256 (verifier)==challenge? 7 { access_token, refresh_token, id_token } 8 GET /api/user Authorization: Bearer access_token Why PKCE? It prevents authorization code interception. Even if an attacker captures the auth code from the redirect, they can't exchange it without the code_verifier (never transmitted).
PKCE binds the auth code to the app that requested it, preventing interception attacks on public clients (SPAs, mobile apps).

ID Token vs Access Token (OIDC)

TokenAudiencePurposeFormat
ID Token Your app (the client) Proves who the user is (authentication) JWT with user claims (name, email, sub)
Access Token Resource server (API) Grants permission to access resources JWT or opaque (depends on provider)

Key distinction: The ID token is for your app to read. The access token is for the API to validate. Never send the ID token to an API as authorization — that's the access token's job.


09 — Attack Vectors & Defenses

TOKEN XSS Attack Inject JS to steal token Defense: HttpOnly cookies CSRF Attack Trick browser into sending cookie Defense: SameSite=Lax Token Replay Reuse a stolen/old token Defense: Rotation + families Open Redirect Redirect auth code to attacker Defense: Whitelist redirect URIs Session Hijacking Intercept token on network Defense: Secure flag + HTTPS
Five primary attack vectors targeting auth tokens, and the specific defense for each.

Token Storage: Where Should Tokens Live?

LocationXSS Vulnerable?CSRF Vulnerable?Verdict
localStorage Yes — JS can read it No — not auto-sent Avoid for auth tokens
sessionStorage Yes — JS can read it No — not auto-sent Avoid for auth tokens
HttpOnly Cookie No — invisible to JS Mitigated with SameSite Recommended
In-memory variable Partially No Lost on refresh; okay for SPAs with refresh token in cookie

The most common auth mistake: storing tokens in localStorage. One XSS vulnerability — a single <script> injection anywhere on your page — and every token is exfiltrated. HttpOnly cookies are invisible to JavaScript by design.


10 — Build vs Buy

Build Custom Auth When: dedicated security team, unique requirements, regulatory compliance, 10+ engineers You own: token lifecycle, rotation, storage, session management, cookie security, CSRF, OAuth integration, audit logs, rate limiting, password hashing, MFA, account recovery... Typical: 5,000-15,000 lines of auth code Use Auth Library/Service When: small team, standard requirements, shipping speed matters, 1-10 engineers Library handles: all of the above, plus keeping up with CVEs, new attack vectors, OAuth spec changes, browser security updates You write: ~50-200 lines of config Time to production: 1-2 days
For a 2-engineer team, the "buy" column is nearly always the right choice.

Solutions Compared

SolutionTypeCostSession StrategyBest For
Auth.js (NextAuth v5) Open-source library Free JWT or DB sessions Next.js apps, full control, no vendor lock-in
Clerk Managed service Free tier, then ~$25/mo Managed (opaque + JWT) Fast launch, beautiful pre-built UI, React-first
Firebase Auth Managed service Free (generous tier) JWT (Firebase tokens) Google ecosystem, mobile + web
Supabase Auth Open-source + hosted Free tier, then $25/mo JWT (short-lived) + refresh Postgres ecosystem, open-source preference
Convex Auth Built into Convex Included with Convex Managed sessions Already using Convex for database

The 2-engineer reality check: Every hour spent on auth plumbing is an hour not spent on product. A custom auth system needs ongoing maintenance — keeping up with browser changes, new attack vectors, and security patches. Auth.js alone handles session rotation, CSRF protection, OAuth flows, and database adapters in ~100 lines of configuration. That's the pound-for-pound value play.

What Each Solution Handles For You

ConcernCustomAuth.jsClerk
Password hashingYou buildBuilt-in (credentials provider)Managed
OAuth integrationYou build per provider50+ providers pre-builtManaged
Session managementYou buildBuilt-in (JWT or DB)Managed
Token rotationYou buildBuilt-inManaged
CSRF protectionYou buildBuilt-inManaged
Rate limitingYou buildNot includedManaged
MFA / 2FAYou buildVia providerManaged
Pre-built UIYou buildMinimalFull components
Security patchesYou monitor + patchCommunity maintainsVendor maintains

Key Takeaways

  1. Two tokens solve a real tradeoff: access tokens are a stateless cache of authorization (fast, not revocable); refresh tokens are the real session (slow, revocable). Together they give you speed without sacrificing revocation.
  2. HttpOnly cookies, always. Never store tokens in localStorage. One XSS and you're compromised.
  3. Refresh token rotation is non-negotiable. Each refresh should issue a new pair and invalidate the old. Replay detection via token families catches stolen tokens.
  4. JWTs are not sessions. They're signed data packets. Great for stateless access tokens, but if you need revocation, you need server-side state (opaque tokens or a JWT blocklist).
  5. Hash before store. Never store raw tokens in the database. SHA-256 hash them. If your DB is breached, attackers get useless hashes.
  6. PKCE is mandatory for public clients. Any OAuth flow from a browser or mobile app must use PKCE to prevent authorization code interception.
  7. For a small team: use Auth.js or Clerk. Custom auth is a 10,000-line commitment with ongoing security maintenance. The ROI rarely makes sense under 10 engineers.