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:

  1. 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.
  2. 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.

Authentication methods sorted by whether the secret travels and whether verification needs server state the secret travels — bearer hold it and you are the user the secret stays home — proof of possession prove the key without sending it verifies itself no lookup needs a lookup state per call JWT · OIDC ID token signed claims — fast, and unrevokable the dangerous quadrant: nothing you do stops a stolen one before it expires Mutual TLS chain validates against a CA, no per-call I/O strongest of the eight — the only one whose key can be bound to hardware API keys · Basic auth opaque bearer tokens a lookup on every request buys you instant revocation HMAC request signing server looks up the shared secret by key id, then recomputes the signature OAuth 2.0 and OpenID Connect are missing on purpose — they are not credentials. They are the protocols that hand you one. Whatever they issue lands in one of these four boxes, which is the only thing that decides how it behaves.
Left column: possession is proof, so theft is total. Right column: the key never crosses the wire, so a captured request is worth much less. Top row: fast, and impossible to revoke early. Bottom row: one lookup per request, and revocation is a delete.

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:

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.

Animated authorization code flow with PKCE — the password is only ever entered at the identity provider BrowserApp serverIdentity providerAPI 1 · authorize request + PKCE challenge 2 · login page — the password is typed here, and only here 3 · authorization code, through the browser 4 · code + PKCE verifier, back-channel 5 · access token (+ refresh, + ID token if OIDC) 6 · Bearer access token the app never sees the password step 4 is why an intercepted code is useless alone Steps 1–3 cross the browser, which is hostile. The token itself is only ever issued on the direct call in step 4.
The authorization code flow with PKCE. Follow the dot: the credential that matters never travels through the browser — only a single-use code does, and without the verifier from step 4 that code buys an attacker nothing.

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

MethodWhat travelsSecret on the wireStateless verifyRevocationReplay-safeIdentifies
API keysthe key itself yes, every callno — key lookupinstant (rotate/delete) noan application
Basic auththe password itself yes, every callno — password verifychange the password noa user
Opaque bearera random token yes, every callno — token lookupinstant noa session
JWTsigned claims yes, every call yesnot until exp noclaims about a subject
OAuth 2.0(issues one of the above)depends on token typedependsrevoke the grantdependsan app acting for a user
OIDCID token (JWT) + access tokenyes, both are bearer for the ID tokenID token not until exp nothe user
HMACa signature, not the key nono — secret lookuprotate the secret with timestamp + noncea key holder, plus the payload
Mutual TLSa certificate + handshake proof no chain validationCRL/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 requesta signature check, no I/Oone cache or database read
Horizontal scaletrivial — any node can verify aloneneeds shared, fast, always-up storage
Revoke a sessionyou can’t — valid until expdelete the row; effective immediately
Change a role or permissionstale until the token is reissuedeffective on the next request
Log out everywherenot possible without adding stateone query
Credential sizegrows with the claims you pack insmall — an opaque identifier
Blast radius if stolenfull access until expiryfull access until you notice
What fails at 3ama leaked token you cannot recallthe token store goes down and all auth fails
Honest mitigationshort exp + refresh tokenscache 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.

AttackAPI key / BasicOpaque bearerJWTHMACMutual TLS
Captured a single requesttotal, until someone rotates ittotal until expirytotal until expthat one request onlynothing useful
Read the verifying server’s databasekeys / password hashes — so hash themtokens — hash these at rest tooHS256: the shared secret mints anything. RS256: only a public key, nothing mintableshared secrets = sign anythingtrust anchors only — but a stolen CA key mints any identity
XSS in the browserreads whatever JS can readsamesamesamekey unreachable from JS — but injected script still rides the session
Stole a laptopwhatever is on diskwhatever is on diskwhatever is on diskwhatever is on disksame — unless the key is non-exportable in a TPM
Alter the payload in transitTLS blocks itTLS blocks itTLS blocks itTLS blocks itTLS blocks it
Alter the payload at an intermediary — terminating proxy, CDN, queue, stored webhookfreefreefreeblocked — the body is signedfree, once TLS has terminated
Replays a captured callworksworksworksblocked if timestamp + nonceblocked (outside 0-RTT)

↔ scroll the table sideways to see every column.

A captured bearer token replays successfully; a captured signed request does not, provided a timestamp and nonce were signed The same attacker captures one request Bearer credential Authorization: Bearer … replayed unchanged, an hour later 200 OK the token is the identity Signed request sig = HMAC(body + ts + nonce) replayed unchanged, an hour later 401 stale timestamp outside the window Sign a timestamp and a nonce, or the right-hand box behaves exactly like the left one — a valid signature over the same bytes is still valid. Neither box helps if the attacker got the key itself. Both assume TLS; without it, everything here is readable in transit.
Replay is the difference between proving you know a secret and proving you meant this request, now. It is not a property of HMAC — it's a property of what you chose to sign.

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:

StorageReadable by JavaScriptSent automaticallyMain riskVerdict
localStorage yesnoany XSS exfiltrates the tokenavoid for credentials
sessionStorage yesnosame, shorter windowavoid for credentials
JS-readable cookie yesyesworst of bothnever
HttpOnly cookie noyesCSRF, because it’s automaticpreferred, with SameSite
In-memory variableyes, but dies on reloadnolost on refresh; still XSS-readablegood 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

SituationUse
Third-party app needs access on a user’s behalfOAuth 2.0, authorization code + PKCE
”Sign in with…” — you need to know who the user isOpenID Connect
Ordinary web app sessionOpaque bearer token in an HttpOnly cookie
Many services must verify without shared session storageJWT, short expiry, with refresh
You must be able to log someone out right nowanything stateful — never a bare JWT
Receiving webhooks from someone elseverify their HMAC in constant time, enforce their timestamp tolerance, dedupe on event id
Designing a signing scheme others will callHMAC over a body that includes a timestamp and a nonce
Service-to-service inside your own networkMutual TLS, ideally via a mesh
Identifying an app for quotas and rate limitsAPI keys
A quick internal endpoint behind TLSBasic 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, SameSite cookie, 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 whose alg you let the token choose, and never a token in localStorage when a cookie the JavaScript can’t read will do — while remembering that HttpOnly stops the credential being stolen, not the session being used, and that nothing on this page is a substitute for not having XSS.

web security