Eight authentication methods — battle
Every list of authentication methods puts these eight side by side as if they were eight options for the same slot. They aren’t. One of them isn’t an authentication method at all, two of them are the same thing described at different layers, and the one everyone reaches for by default is the one you can’t take back.
Before the contenders, two questions sort the whole field — and they sort it better than any feature list:
- Does the secret travel on every request? If yes, you have a bearer credential: whoever holds it is you. If no, the holder has to prove possession of a key that never leaves them.
- Can you take it back? Some credentials are checked against server state, so revoking is a
DELETE. Others verify themselves with pure maths, which is exactly why nothing you do can stop them until they expire.
Those two axes give you a 2×2, and every method on the list lands in one of the four boxes.
First, three things the usual list gets wrong
Bearer isn’t a format, and JWT isn’t a presentation mode. “Bearer token” describes how a credential is presented — Authorization: Bearer <something> — and says nothing about what the something is. “JWT” describes what’s inside: base64url header, claims and signature. Almost every JWT you’ll meet is presented as a bearer token, so they’re not alternatives; they’re two different answers about the same credential. The real choice hiding behind “bearer vs JWT” is opaque vs self-contained, which is the stateless question below.
OAuth 2.0 is not an authentication method. It’s a delegation framework: it lets an application act on your behalf against an API without ever seeing your password. It answers “what may this app do?”, not “who is this person?” Using a bare OAuth access token to identify a user is one of the most common security mistakes in this whole area — the token is meant for the API, it may be opaque to the client, and nothing in OAuth says it describes the person who authorised it. Hand a naive client someone else’s valid token and it will happily treat that as a login.
OpenID Connect is the fix for exactly that. It’s a thin identity layer on top of OAuth 2.0 that adds an ID token — a JWT, meant for the client, saying who authenticated and when. So: access token = what you may do, ID token = who you are. Send the right one to the right place and a whole class of bugs disappears.
The contenders
1 · API keys — naming the caller, not the user
A long random string presented on every request. Its real job is rarely security in the strict sense: it identifies an application so you can rate-limit it, meter it, and cut it off. It says nothing about which human is behind the call.
It is a pure bearer secret with no expiry, which means a leaked key is full access until someone notices and rotates it — and keys leak constantly, because they end up in git history, mobile binaries, CI logs and query strings. Put it in a header, never in a URL: query strings land in access logs, browser history and Referer headers.
Verdict: correct for server-to-server identification, quotas and metering. Never a substitute for user authentication, and never something you ship inside a client anyone can decompile.
2 · Basic auth — the password, every single time
Authorization: Basic followed by base64 of user:password. The thing to internalise: base64 is an encoding, not encryption. It is trivially reversible, so over plain HTTP you are broadcasting the password on every request, to every proxy on the path.
Even over TLS it has an underrated cost. Because the raw password arrives on every call, the server must verify it against a password hash every call — and a correctly-configured Argon2id or bcrypt is deliberately slow, tens to hundreds of milliseconds of CPU, and in Argon2’s case deliberately memory-hungry too (a common baseline reserves around 19 MiB per concurrent verification). Do that per request and your password hashing becomes your load profile.
The way out is to cache the verification result for a short window, so the expensive hash runs once per credential rather than once per request — this is standard rather than exotic; Apache ships it as mod_authn_socache. Note the asymmetry that makes it safe: failed attempts aren’t cached, so brute-force traffic still pays full price. Teams that neither cache nor budget for the CPU have usually just weakened their hashing to afford the traffic, which is the worst of the three options.
Verdict: fine for an internal endpoint behind TLS, a health check, or a script. Not a design for a real API, and never without HTTPS.
3 · Opaque bearer tokens — the boring one that revokes instantly
Log in once, get a random string, present it on every request; the server looks it up. The token means nothing by itself — all the meaning lives in the row it points at.
That lookup is usually cast as the drawback. It’s also the entire feature: because the server consults state on every request, revocation is immediate and total. Delete the row, log out every session, ban an account, force re-auth after a password change — all of it is a write, and it takes effect on the very next request. Nothing on the top row of that quadrant can do this. (One caveat you own the moment you optimise: if you cache the lookup, revocation is immediate to the cache TTL, not to the request. That’s the same trade a short-lived JWT makes, only smaller — and you should pick the TTL knowing that.)
Verdict: the sane default for sessions in a normal application. Pair short expiry with a refresh token, and keep the lookup in a fast cache.
4 · JWT — signed claims, and the revocation problem
A JWT carries its own claims — subject, role, expiry, audience, issuer — signed so the server can verify them with a key and no database round trip. That’s the appeal, and it’s real: verification is a signature check, so any node can authenticate a request without shared session storage.
Three things routinely go wrong:
- A JWT is signed, not secret. Anyone holding it can read every claim — it’s base64url, not encryption. Never put anything in a JWT you wouldn’t hand to the bearer. (Encrypted JWTs do exist, and almost nobody uses them.)
- The algorithm is an input, and inputs are attacker-controlled. A verifier that trusts the token’s own
algheader can be toldnone, or told to treat an RS256 public key as an HS256 shared secret. Pin the expected algorithm and key in your verification code; never let the token choose. - Validate more than the signature. A signature only proves the token is intact. You must also check
exp, and checkissandaud— otherwise a perfectly valid token minted for a different service, by the same identity provider, is accepted by yours.
And then the structural one: you cannot revoke a stateless token. Someone steals it, or a user is fired, or a role is downgraded — the token stays valid until exp, because verification never asks anyone’s permission. Every fix reintroduces the state you were avoiding: a denylist, a token-version column, or short expiry with refresh (a denylist you check rarely instead of always). Short expiry is the honest answer; it converts an unbounded exposure into a bounded one.
Verdict: excellent when many services must verify independently and a few minutes of stale authority is acceptable. A poor fit for anything needing immediate logout or instant privilege changes — and a bad place to keep a long-lived session.
5 · OAuth 2.0 — delegation, so the password stays home
The problem OAuth solves is the one where an app wants to read your calendar and the naive solution is “give it your Google password.” Instead the app redirects you to the provider, you authenticate there, and the app receives a token scoped to exactly what it asked for. Your password never reaches the third party, and you can withdraw the grant without changing it.
The flow that matters is the authorization code flow with PKCE. The code comes back through the browser, which is a hostile place, so the app then exchanges it over a direct back-channel call — and PKCE binds that exchange to a secret only the original app knows, so an intercepted code is useless to anyone else. PKCE started as a mobile-app patch and is now recommended for essentially everything.
Two older grants you should recognise in order to avoid. Implicit returned the access token directly in the authorization response, where it leaks through Referer headers and browser history — and, more seriously, where nothing prevents an attacker injecting a token they obtained elsewhere, because there’s no standard way to bind a token issued that way to the client that asked for it. Resource owner password credentials has the app collect the user’s password directly, destroying the only reason to use OAuth in the first place; the current security guidance says flatly that it must not be used. Neither survives into OAuth 2.1.
Verdict: the right answer whenever a third party needs access on a user’s behalf. Use the authorization code flow with PKCE; treat any design that asks the app to handle the user’s password as a red flag.
6 · OpenID Connect — the identity half
OAuth hands out permission. OIDC adds the missing sentence: who was that. It is a genuine authentication protocol — it exists so a client can verify the identity of the end user — and it standardises an ID token, a JWT whose required claims are iss, sub, aud, exp and iat, plus nonce whenever the client sent one. That nonce matters more than its size suggests: it is the specific defence against someone injecting a different, perfectly valid ID token into your login flow. Alongside it come a /userinfo endpoint and a discovery document — a separate specification from Core — so clients can find keys and endpoint locations rather than hardcoding them.
The discipline it demands is simple and constantly violated: the ID token is for the client, the access token is for the API. The client validates the ID token to establish a session; the API never accepts it in place of an access token. Sending an ID token to an API as if it were an access token means the API is trusting a credential minted for someone else’s audience — which is precisely what the aud claim exists to stop.
Verdict: what you actually want whenever “sign in with…” is the requirement. Reach for OIDC rather than assembling identity out of raw OAuth by hand.
7 · HMAC request signing — integrity, not just identity
Client and server share a secret. The client hashes a canonical string — method, path, a digest of the body, a timestamp, a nonce — with that secret and sends the signature alongside the request. The server rebuilds the same string and recomputes.
Two properties nothing above has. The secret never crosses the wire, so capturing a request doesn’t yield the credential. And the signature covers the body, so it proves integrity: nobody altered the payload in flight. That’s why it’s the standard for webhooks — the receiver has no session with you and needs to know the payload is authentic — and for cloud API signing.
The trap is replay. A signature alone is happily replayable: capture a valid signed request and send it again, and it verifies, because it’s still a genuine signature over the same bytes. Replay protection comes from including a timestamp and a nonce in the signed material and having the server reject stale timestamps and remembered nonces. Also compare signatures in constant time — a naive == leaks the correct value one byte at a time through timing.
If you are on the receiving end — verifying someone else’s webhooks — remember you don’t get to choose what they signed. Some providers sign a timestamp and give you a tolerance window; some sign only the body and expect you to deduplicate on their event id. So the receiver’s job is: verify in constant time, enforce whatever timestamp window the sender offers, and dedupe on the event id, which is what actually closes the replay gap inside the window.
Verdict: the right tool for webhooks, machine-to-machine calls and anything where payload integrity matters as much as caller identity. Only replay-safe if you signed a timestamp and a nonce and the server actually checks them.
8 · Mutual TLS — both sides prove it, during the handshake
Ordinary TLS authenticates the server to the client. Mutual TLS adds the other direction: the client presents an X.509 certificate too, and proves possession of the matching private key by signing the handshake with it. The private key never moves, and the client is authenticated before it sends a single byte of your application protocol. Two caveats for the pedantic: a TLS 1.3 server may send early response data before the client’s certificate has arrived, and 0-RTT early data is explicitly replayable — so nothing non-idempotent belongs in it.
It is the strongest option on this list, and the most operationally expensive. You now run a certificate authority: issuing, distributing, rotating and revoking certificates for every workload. Revocation is the hard part — CRLs and OCSP are awkward enough that the modern answer is usually short-lived certificates that expire faster than you could revoke them. This is exactly what service meshes automate, which is why mTLS feels free inside a mesh and painful outside one.
Verdict: the default for service-to-service trust in a zero-trust network, and worth it in high-value contexts. Rarely appropriate for ordinary end users, who cannot be asked to manage certificates.
The eight, side by side
| Method | What travels | Secret on the wire | Stateless verify | Revocation | Replay-safe | Identifies |
|---|---|---|---|---|---|---|
| API keys | the key itself | ✗ yes, every call | no — key lookup | instant (rotate/delete) | ✗ no | an application |
| Basic auth | the password itself | ✗ yes, every call | no — password verify | change the password | ✗ no | a user |
| Opaque bearer | a random token | ✗ yes, every call | no — token lookup | instant | ✗ no | a session |
| JWT | signed claims | ✗ yes, every call | ✓ yes | not until exp | ✗ no | claims about a subject |
| OAuth 2.0 | (issues one of the above) | depends on token type | depends | revoke the grant | depends | an app acting for a user |
| OIDC | ID token (JWT) + access token | yes, both are bearer | ✓ for the ID token | ID token not until exp | ✗ no | the user |
| HMAC | a signature, not the key | ✓ no | no — secret lookup | rotate the secret | ✓ with timestamp + nonce | a key holder, plus the payload |
| Mutual TLS | a certificate + handshake proof | ✓ no | ✓ chain validation | CRL/OCSP, or short-lived certs | ✓ yes (per-session keys) | a workload or device |
↔ scroll the table sideways to see every column.
Crossing the axis: sender-constrained tokens
That table treats “bearer or proof-of-possession” as a property of the method you picked. It isn’t fixed — and if what worries you is the left column’s defining weakness, that a captured token is the identity, then the real answer is not “switch to mutual TLS.” There are two standardised ways to take an ordinary OAuth token and move it into the right-hand column.
DPoP binds a token to a key pair the client generates. The client sends a small signed proof alongside the token on every request, and the authorization server marks the token as belonging to that public key. Steal the token and you have half of a credential: the private key never left the client, so a replayed token fails. It works at the application layer, which means it works in a browser — the place where mutual TLS never will.
Certificate-bound access tokens do the same job one layer down: the token is tied to the client’s TLS certificate when it’s issued, and the API checks the certificate presented on the connection matches the one the token was bound to. Natural fit for machine clients already doing mutual TLS.
Both rewrite the worst row in the attacker table. “Captured a single request → total until expiry” becomes “captured a single request → useless without the key.” Neither is free: DPoP costs a signature per request and needs the server to track proofs to stop those being replayed, and certificate binding costs you mutual TLS everywhere. But it’s worth knowing that the axis this whole article is built on is a decision you can make, not a fact you inherit from the token format.
Stateless or stateful: the trade nobody escapes
This is the decision the rest of them hang off, so it deserves its own table. It is not really “JWT vs sessions” — a JWT is a format, and you can keep state alongside one. It’s self-contained verification versus a lookup, and the axis runs between speed and control.
| Stateless (verify the signature) | Stateful (look the token up) | |
|---|---|---|
| Cost per request | a signature check, no I/O | one cache or database read |
| Horizontal scale | trivial — any node can verify alone | needs shared, fast, always-up storage |
| Revoke a session | you can’t — valid until exp | delete the row; effective immediately |
| Change a role or permission | stale until the token is reissued | effective on the next request |
| Log out everywhere | not possible without adding state | one query |
| Credential size | grows with the claims you pack in | small — an opaque identifier |
| Blast radius if stolen | full access until expiry | full access until you notice |
| What fails at 3am | a leaked token you cannot recall | the token store goes down and all auth fails |
| Honest mitigation | short exp + refresh tokens | cache aggressively; treat the store as tier-0 |
↔ scroll the table sideways to see every column.
The practical resolution most teams land on is a hybrid, and it’s worth stating plainly: short-lived stateless access tokens plus a long-lived stateful refresh token. Access tokens are checked without I/O and expire in minutes; the refresh token is a database row you can delete, and deleting it stops renewal. You get most of the speed and keep a revocation lever, at the cost of a window — the access-token lifetime — during which a stolen token still works. Choosing that window is the security decision. Fifteen minutes is a common answer; the right one depends on what a stolen token can do.
What an attacker actually gets
Comparison tables tend to stop at features. This is the one that decides how bad your worst day is.
| Attack | API key / Basic | Opaque bearer | JWT | HMAC | Mutual TLS |
|---|---|---|---|---|---|
| Captured a single request | total, until someone rotates it | total until expiry | total until exp | that one request only | nothing useful |
| Read the verifying server’s database | keys / password hashes — so hash them | tokens — hash these at rest too | HS256: the shared secret mints anything. RS256: only a public key, nothing mintable | shared secrets = sign anything | trust anchors only — but a stolen CA key mints any identity |
| XSS in the browser | reads whatever JS can read | same | same | same | key unreachable from JS — but injected script still rides the session |
| Stole a laptop | whatever is on disk | whatever is on disk | whatever is on disk | whatever is on disk | same — unless the key is non-exportable in a TPM |
| Alter the payload in transit | TLS blocks it | TLS blocks it | TLS blocks it | TLS blocks it | TLS blocks it |
| Alter the payload at an intermediary — terminating proxy, CDN, queue, stored webhook | free | free | free | blocked — the body is signed | free, once TLS has terminated |
| Replays a captured call | works | works | works | blocked if timestamp + nonce | blocked (outside 0-RTT) |
↔ scroll the table sideways to see every column.
Where do you keep it in a browser?
The question that generates the most heat and the least clarity. There is no option without a trade-off:
| Storage | Readable by JavaScript | Sent automatically | Main risk | Verdict |
|---|---|---|---|---|
localStorage | ✗ yes | no | any XSS exfiltrates the token | avoid for credentials |
sessionStorage | ✗ yes | no | same, shorter window | avoid for credentials |
| JS-readable cookie | ✗ yes | yes | worst of both | never |
HttpOnly cookie | ✓ no | yes | CSRF, because it’s automatic | preferred, with SameSite |
| In-memory variable | yes, but dies on reload | no | lost on refresh; still XSS-readable | good for short-lived access tokens |
The honest summary: HttpOnly + Secure + SameSite cookies, with CSRF protection. The common counter-argument — “cookies are vulnerable to CSRF” — is true and solvable with SameSite plus a token, while the localStorage alternative hands your credential to any successful XSS in a form the attacker can walk away with.
But be precise about what you’ve bought, because this is where a lot of writing on the subject quietly overstates the win. HttpOnly protects the confidentiality of the credential, not the session. An attacker running script on your origin can still issue same-origin requests, and the browser will attach the cookie to every one of them. What you have actually done is downgrade XSS from permanent, portable, offline credential theft to session riding for as long as the compromise lasts. That is a genuinely large win and it justifies the recommendation — but no cookie flag is an answer to XSS. Not having XSS is the answer to XSS.
The stronger architecture, and the one current guidance puts above both options, is a backend-for-frontend: the browser holds only a session cookie, and a small server-side component you control holds the real tokens and attaches them to outbound API calls. The SPA never touches a token at all. The commonly-seen shape — refresh token in an HttpOnly cookie, access token in memory — is a reasonable second tier, not the ideal.
Choosing, in one table
| Situation | Use |
|---|---|
| Third-party app needs access on a user’s behalf | OAuth 2.0, authorization code + PKCE |
| ”Sign in with…” — you need to know who the user is | OpenID Connect |
| Ordinary web app session | Opaque bearer token in an HttpOnly cookie |
| Many services must verify without shared session storage | JWT, short expiry, with refresh |
| You must be able to log someone out right now | anything stateful — never a bare JWT |
| Receiving webhooks from someone else | verify their HMAC in constant time, enforce their timestamp tolerance, dedupe on event id |
| Designing a signing scheme others will call | HMAC over a body that includes a timestamp and a nonce |
| Service-to-service inside your own network | Mutual TLS, ideally via a mesh |
| Identifying an app for quotas and rate limits | API keys |
| A quick internal endpoint behind TLS | Basic auth — and nothing public |
TL;DR
TL;DR: Two questions sort all eight. Does the secret travel? API keys, Basic auth, bearer tokens and JWTs are bearer credentials — hold one and you are the user, so capture is total. HMAC and mutual TLS are proof of possession — the key never crosses the wire, so a captured request is worth little. And that axis is a choice, not a fixed property: DPoP and certificate-bound tokens move an ordinary OAuth token into the proof-of-possession column, which is the real answer to “a stolen token is game over”. Can you take it back? Anything verified by lookup revokes on the very next request — or on your cache TTL, if you cached the lookup; anything verified by signature alone — a JWT — cannot be revoked before it expires, and every fix for that quietly reintroduces the state you were avoiding.
Three corrections to the usual framing: bearer is how a credential is presented and JWT is what’s inside it, so they were never alternatives; OAuth 2.0 is delegation, not authentication — an access token tells you what an app may do, never who the user is; and OpenID Connect is the layer that answers identity, via an ID token meant for the client while the access token goes to the API.
Practical defaults: sessions → opaque token in an
HttpOnly,Secure,SameSitecookie, or better, a backend-for-frontend where the browser never holds a token at all. Distributed verification → short-lived JWT plus a stateful refresh token, and pick the access-token lifetime deliberately, because that window is your exposure. Webhooks you receive → verify in constant time, honour the sender’s timestamp window, and dedupe on their event id; webhooks you design → sign a timestamp and a nonce, or you’ve built a replay machine. Service-to-service → mutual TLS with short-lived certificates. Never Basic auth without TLS, never a credential in a query string, never a JWT whosealgyou let the token choose, and never a token inlocalStoragewhen a cookie the JavaScript can’t read will do — while remembering thatHttpOnlystops the credential being stolen, not the session being used, and that nothing on this page is a substitute for not having XSS.