From first principles to production architecture. How tokens, sessions, and OAuth actually work under the hood — and when to build vs buy.
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.
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:
Tokens travel via cookies. Four flags control their security:
| Flag | What it does | Prevents |
|---|---|---|
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.
The single most common question: why two tokens instead of one?
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.
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | 5–15 minutes | 7–30 days |
| Where it's sent | Every API request | Only to /refresh endpoint |
| Validated how | Signature check (no DB) | Hash lookup in DB |
| Revocable | No (wait for expiry) | Yes (delete from DB) |
| Stored server-side | No | Yes (as SHA-256 hash) |
| If stolen | 15 min of damage max | Full 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.
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.
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:
exp| Pitfall | What goes wrong | Prevention |
|---|---|---|
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 |
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.
| Property | JWT | Opaque Token |
|---|---|---|
| Contains data? | Yes (claims in payload) | No (just a random string) |
| DB lookup per request | No | Yes (every request) |
| Revocable instantly | No (needs blocklist) | Yes (delete from DB) |
| Token size | ~800 bytes+ | ~44 bytes (32 bytes Base64) |
| Breach impact | Token is self-contained; usable as-is | Only hashes in DB; unusable |
| Horizontal scaling | Easy (stateless) | Needs shared session store |
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.
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
Decide your policy: how many active sessions per user?
Store metadata alongside the session hash: user agent, IP, last active timestamp, device fingerprint. This enables:
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:
This is the "nuclear option" — but it's the only safe response because the server can't tell which party is legitimate.
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)
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.
| Token | Audience | Purpose | Format |
|---|---|---|---|
| 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.
| Location | XSS 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.
| Solution | Type | Cost | Session Strategy | Best 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.
| Concern | Custom | Auth.js | Clerk |
|---|---|---|---|
| Password hashing | You build | Built-in (credentials provider) | Managed |
| OAuth integration | You build per provider | 50+ providers pre-built | Managed |
| Session management | You build | Built-in (JWT or DB) | Managed |
| Token rotation | You build | Built-in | Managed |
| CSRF protection | You build | Built-in | Managed |
| Rate limiting | You build | Not included | Managed |
| MFA / 2FA | You build | Via provider | Managed |
| Pre-built UI | You build | Minimal | Full components |
| Security patches | You monitor + patch | Community maintains | Vendor maintains |