API Authentication: Sessions, JWTs, API Keys and OAuth Compared
The real trade-offs between session cookies, JWTs, API keys and OAuth — including why stateless JWTs make logout hard and what to do about it.
Table of contents
Four mechanisms, each right for a different situation. The most common mistake is picking JWTs for a normal web app because they are "modern".
Session cookies#
The server stores session state; the client holds an opaque id in a cookie.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800Strengths: instant revocation (delete the row), tiny cookie, HttpOnly means JavaScript cannot read it so XSS cannot steal it, and the browser handles transmission automatically.
Weaknesses: requires session storage, and needs CSRF protection because cookies are sent automatically on cross-site requests.
SameSite=Lax blocks the cross-site POST that most CSRF attacks need, which covers a lot. It is not a complete substitute for a CSRF token on state-changing requests.
Use for: browser-based web applications. This is the right default and it is under-used.
JWTs#
A signed token containing claims. The server verifies the signature and trusts the contents without a lookup.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Strengths: stateless verification, so any service with the key can validate without a shared session store. Genuinely valuable across service boundaries.
Weaknesses, and they are real:
- You cannot revoke one. A stolen token is valid until it expires. The usual mitigation — a revocation list — reintroduces the state you chose JWTs to avoid.
- Claims go stale. A token minted with
role: adminstill saysadminafter you demote the user. - They are bigger than a session id, on every request.
- A JWT is not encrypted. Base64url is not encryption; anyone holding the token can read the payload. Never put anything sensitive in it.
The standard pattern that makes JWTs workable:
Access token: 15 minutes, in memory
Refresh token: 7-30 days, HttpOnly cookie, rotated on every useShort access-token lifetimes bound the damage of theft. Rotating refresh tokens — issuing a new one and invalidating the old on each use — means a stolen refresh token is detectable: if an old one is presented, the family is compromised and you revoke it all.
API keys#
A long random string identifying a machine caller.
Strengths: trivial to implement, easy to rotate, easy to scope per key.
Weaknesses: no user identity, no expiry unless you build it, and they leak — into git, into CI logs, into screenshots.
Practical requirements: a visible prefix (sk_live_...) so secret scanners can detect a leak; storage as a hash, not plaintext, so a database breach does not expose working keys; and display of the full value exactly once at creation.
Use for: server-to-server calls and developer-facing APIs.
OAuth 2.0 / OIDC#
Delegated authorisation — a user grants your app access to their account elsewhere.
Use for: "Sign in with Google", and any case where you need access to a third party's data on a user's behalf. Do not build it yourself for first-party login; the flows have subtle security requirements and a library or provider will get them right.
Use the Authorization Code flow with PKCE. The implicit flow is deprecated and should not appear in new code.
Choosing#
| Situation | Use |
|---|---|
| Browser web app, one backend | Session cookies |
| Mobile app or SPA with a separate API | Short JWT + rotating refresh cookie |
| Service-to-service inside your system | Short-lived JWT or mTLS |
| Public developer API | API keys, scoped |
| "Sign in with X" | OAuth 2.0 + PKCE |
What matters regardless of mechanism#
- HTTPS only. Any credential over plain HTTP is compromised.
- Rate-limit the login endpoint. Credential stuffing is the most common real attack.
- Hash passwords with Argon2id, bcrypt or scrypt. Never SHA-256 — it is fast, which is exactly wrong for passwords.
- Authorise on every request, not just authenticate. "Is this a valid user?" and "may this user do this to this record?" are different questions, and the second one is where the real vulnerabilities live.
Frequently asked questions#
Is a JWT more secure than a session?#
No — it is more scalable to verify. On security it is arguably worse for browser apps, because revocation is hard and the token is often stored somewhere readable.
How long should an access token live?#
15 minutes to an hour. Long enough to avoid constant refreshes, short enough that a leak is bounded.
Do I need CSRF protection with Bearer tokens?#
Not for the token itself — browsers do not attach Authorization headers automatically. You do need it for any cookie-based credential.
Where do I store a token in a mobile app?#
Keychain on iOS, Keystore on Android. Not in plain preferences or a local file.
Related reading#
- REST API Design Best Practices
- Next.js Server Actions — where authorisation is easiest to forget
- Inspect a token safely with the JWT Decoder — it runs entirely in your browser.