Backend · APIs & Auth
Auth: JWTs, sessions, OAuth, what to actually use
A decision tree for picking the right auth approach. JWTs are not the answer to every question, sessions are not obsolete, and most teams over-engineer this.
The authentication discourse online is bizarre. Half the posts say "JWTs are evil." The other half say "sessions are obsolete; use JWTs." The truth is the same as everywhere in engineering: it depends, and the people most loudly recommending one option for everything are probably wrong.
Here's the actual decision tree.
Step 1: figure out what you're authenticating#
Three different problems often called "auth":
- Web app login — user signs in, browser keeps them signed in.
- API access — third-party developer makes server-to-server calls.
- Service-to-service — your
auth-servicetalks to yourpayments-serviceinternally.
Different problems, different solutions. The mistake is assuming "auth" is one thing.
Web app login: just use sessions#
Default for the next decade. Server creates a session, stores (session_id → user_id) in Redis or DB, sets a cookie:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000Pros:
- Revocation works. Logout deletes the session row. Done.
- No token rotation complexity. The session_id is the source of truth.
- Browser handles it for free. HttpOnly + Secure + SameSite covers the basics. No client-side token storage drama.
- Easy to introspect. One DB query to see who's currently logged in, where, and how long.
Cons:
- Requires a session store. (Redis or DB row. Not a big lift.)
- Each request reads the store. (1 cache lookup per request. Not a perf issue.)
The "sessions don't scale" complaint is twenty years old and hasn't been true for ten. Modern Redis handles 100K session lookups per second on a small instance. Sessions are fine.
When NOT sessions#
Cases where stateless tokens (JWTs) are genuinely better:
- API where each call has overhead-sensitive auth check. A high-fanout microservices system where every request hits 8 services and you don't want each one to round-trip to the session store. JWT validates locally.
- Cross-domain SSO where you genuinely can't share a session store. Single sign-on across multiple unrelated apps.
- OAuth-issued tokens for third-party API access. Your customers' integrations.
Outside those: don't.
JWTs done right#
If you're going to use JWTs:
HEADER (algorithm, type)
PAYLOAD (claims)
SIGNATURE (HMAC or RSA over the header.payload)Sign with RS256, not HS256, for any service-to-service token. RS256 lets the issuer hold the private key and verifiers hold the public key. No shared secret to leak.
Use short expirations. 15 minutes is the right ballpark for access tokens. The whole point of JWTs is that revocation is hard, so don't issue tokens that live for hours.
Refresh tokens stored server-side. The pattern: short-lived JWT access token + long-lived refresh token (which IS stored server-side and revocable). Your client uses the refresh token to mint new access tokens. Best of both worlds.
Validate every claim that matters. iss (issuer), aud (audience), exp (expiration), nbf (not-before). The library probably validates them by default. Don't disable them.
Don't put sensitive data in the payload. JWTs are signed, not encrypted (unless you use JWE). The payload is base64-readable. No PII, no secrets.
The single most common JWT mistake: long expirations with no revocation. A 7-day token whose user logged out on day 2 keeps working until day 7. That's a security hole, not a feature.
OAuth 2.0: when third parties access your API#
OAuth is for "let users authorize a third-party app to access their data on your service."
The 2026 default: Authorization Code Flow with PKCE.
1. User clicks "Sign in with Acme" on partner-app
2. partner-app redirects browser to acme.com/oauth/authorize?client_id=...&code_challenge=...
3. User logs in to Acme, approves
4. Acme redirects back to partner-app/callback?code=xxx
5. partner-app POSTs (code, code_verifier) to acme.com/oauth/token
6. Acme returns access_token + refresh_tokenPKCE (Proof Key for Code Exchange) prevents the code being intercepted in transit. It's been required for public clients (mobile, SPAs) since OAuth 2.1.
Don't roll your own. Use a library: oauthlib (Python), passport-oauth2 (Node), Spring Security OAuth (Java). Auth is one of the few places where "I'll just write it myself" reliably ends in a CVE.
If you're providing OAuth: tools like Hydra, Keycloak, or Auth0 manage the flow. Building it yourself takes months.
Service-to-service: simpler than you think#
For internal service-A → service-B:
- mTLS if you control both sides and operate a service mesh. Each service has a cert; mutual TLS handshake establishes identity. No app-level token at all.
- Static service tokens if you don't have mTLS yet. Issue a long-lived token to each service at deploy time. Rotate yearly.
- Internal JWT with short TTL if your services are scattered across teams and audit needs are heavy.
You don't need OAuth between your own services. You need a way for service-A to prove it's service-A, and that's it.
The mistakes that bite#
Storing JWTs in localStorage. XSS-vulnerable. Use HttpOnly cookies for browser-scoped tokens.
Long-lived sessions without re-auth. Sensitive operations (changing password, transferring money) should re-prompt for credentials regardless of session age.
Forgetting to revoke on logout. Some implementations clear the cookie but don't delete the session. Now an attacker with the session ID still has access.
Sharing sessions across subdomains carelessly. Domain=.acme.com cookies leak to every subdomain. Including the marketing site that runs on a CMS your team doesn't audit.
Not rate-limiting login. Plaintext password brute-force is alive and well. Limit by IP, by username, by both.
Skipping CSRF tokens. Sessions without CSRF protection get hijacked via cross-site requests. SameSite=Lax helps but isn't sufficient.
Not auditing. Every login, every failed login, every session creation, every privileged action — log it. Audit logs are the difference between "we were breached on July 12" and "we don't know when we were breached."
A concrete recommendation#
Default web app: Django/Rails/Laravel-style sessions. Cookie auth. Server-side session storage. Done.
Default API: API keys (long-lived, hashed in DB) for server-to-server. OAuth for third-party. JWTs only when you have a specific reason.
Default microservices: mTLS if you have a service mesh; static service tokens otherwise.
Default mobile/SPA: Backend-for-Frontend (BFF) pattern — the SPA talks to your backend with a session cookie; your backend talks to upstream APIs with whatever auth those need. Avoids putting tokens in the browser entirely.
Don't over-engineer. Most teams need cookies + sessions + an OAuth flow when they need third-party integration. That's a complete auth story for years.
Further reading#
- OWASP Authentication Cheat Sheet — required reading.
- "Stop using JWT for sessions" — old but the argument still holds.
- Auth0's JWT debugger — useful for inspecting tokens during dev.
- OAuth 2.0 Simplified — Aaron Parecki's free book; the best OAuth resource.