API Auth
Show me the token
JWT, OAuth, bearer, OpenID Connect, on-behalf-of, backend-for-frontend, SAML, mTLS. They aren't rival options; they answer different questions. Here every one of them runs for real in your browser, and you can open every request and read the token, the redirect and the check that said no.
How this works, and how it's checked
There's a small pretend internet in this page. Browsers, apps, an auth server, APIs and an attacker each answer on a hostname under .example (a name reserved for examples, so nothing here points anywhere real). When one sends another a request, the page writes down the exact HTTP that would have crossed the wire, and every check the receiver made before it answered.
Nothing that matters is faked. Tokens are signed and checked with your browser's own WebCrypto: RS256 is RSA with SHA-256, HS256 is HMAC-SHA-256. PKCE challenges are real SHA-256 hashes. Cookies follow the browser's real SameSite rules. The keys are made fresh when you open the page and never leave it.
The site's build checks play every story on this page, every combination of its buttons, and fail if any one ends differently from what the text says. The crypto is checked against the published test vectors (RFC 7636 for PKCE, RFC 7515 for HS256) and every signed token is verified a second time by node's own crypto library. Then deliberate bugs are planted in the auth server (skip PKCE, skip the audience, trust the token's alg) to make sure the checks notice.
Where real systems differ in detail, this follows the RFCs and the most common practice, and says so.
Six questions, not six choices
"JWT vs OAuth vs bearer" is the wrong fight. Each word answers a different question, and a real system answers all of them.
A typical login on the modern web: OpenID Connect tells the app who you are, using OAuth's code flow with PKCE. The app gets an access token, which is a JWT. It sends it as a bearer token, from its own server, which gave your browser a cookie. That's six of these words in one sign-in, and none of them is an alternative to another.
Two more words sit underneath everything. Authentication asks who you are. Authorization asks what you may do. Most bugs on this page come from mixing those two up.
Passwords on every request, and what 401 and 403 mean
The oldest way to authenticate an API call is to send the password with it, every time.
HTTP Basic authentication puts username:password in the Authorization header, base64-encoded. Base64 just writes bytes as text. It isn't encryption. Click the coloured header in the request and decode it.
Watch the status codes. 401 Unauthorized really means "unauthenticated": I don't know who you are. It must come with a WWW-Authenticate header that tells the client how to identify itself. 403 Forbidden means the opposite: I know exactly who you are, and you still can't do this. Signing in again won't help with a 403.
Signed in isn't allowed: whose order is it?
The API knows exactly who Eve is. The bug is that it never asks whether the thing she asked for is hers.
The 403 in step 1 was about a whole page: Ana isn't an admin. Most data isn't shaped like that. Every customer may call GET /orders/{id}, and the real question is which ids. An API that checks you're signed in and then runs SELECT … WHERE id = ? lets anyone signed in read anyone's orders, by counting.
This is broken object-level authorization (BOLA, also called IDOR, an insecure direct object reference). It's number one on the OWASP API Security Top 10, in both the 2019 and 2023 editions. Nothing about it is exotic: a real user, a real password, and one changed number.
WHERE id = ? AND owner = ?), and answer 404 rather than 403 so ids can't be probed. Random ids slow the counting down, but they aren't a check.API keys: a password for a program
One long random secret per caller. Simple, and it stands for an account, not a person.
An API key is a password that nobody has to remember. The API stores only a hash of it, the way it would a password, and looks the key up on every call. A fixed prefix like shop_live_ costs nothing and lets secret scanners (GitHub's, for one) spot a leaked key in a public repository.
Where you put it matters. In a header it stays out of logs. In the URL it ends up in access logs, proxy logs, monitoring tools and browser history, because all of them write down URLs.
Session cookies: the token you already use
Sign in once, get a random id, and the browser sends it back with every request, by itself.
A session cookie is a token. The server keeps a table of sessions, and the cookie is the key to one row. The browser attaches cookies automatically to every request to the site that set them. That's what makes the web feel signed-in.
It's also the problem. If a page on another site makes your browser send a request to the shop, does your cookie go along? That's cross-site request forgery (CSRF). The cookie's SameSite attribute decides: Strict only on requests from the same site, Lax also on ordinary links from elsewhere (top-level GETs), None always.
The other flags matter too. HttpOnly hides the cookie from JavaScript. Secure keeps it off plain HTTP.
Bearer tokens: whoever holds it
Authorization: Bearer … means "the bearer of this token is allowed in". The API checks the token, never the person holding it.
A bearer token is like a cinema ticket. The usher checks that the ticket is real and for this film. Whose pocket it came out of doesn't matter. Most tokens on the web work this way, and that's fine, as long as you accept what follows: a copied token works for whoever copied it.
So bearer tokens are made to be short-lived (minutes, not months), narrow (they say exactly what they allow), and kept out of anywhere they could be copied from: logs, URLs, browser storage.
A JWT, opened up
Three chunks of base64url, joined by dots. Two of them are JSON you can read. The third is a signature.
A JSON Web Token (JWT, said "jot") carries its own meaning. The header says how it was signed. The payload holds the claims: who it's about (sub), who issued it (iss), who it's for (aud), when it expires (exp), and what it allows (scope). The signature covers the first two, byte for byte.
The important part: a JWT isn't secret. Anyone holding one can read every claim in it. The signature doesn't hide anything. It proves who wrote the token, and that nobody has changed a byte since. If you need the contents hidden, that's a different standard (JWE), and most access tokens don't use it.
The API's checklist
A valid signature isn't enough. The API has to check the algorithm, the key, the issuer, the audience and the clock, in that order.
Each check stops a real attack:
- Signature. Edit one claim and the signature no longer matches. You can't re-sign it without the private key.
- Audience. A real token for the billing API shouldn't open the orders API, even though the same auth server signed both.
- Verifying at all. JWT libraries have two functions: one decodes the claims, one checks them. Calling decode where verify belonged is a common bug, and it passes every test that uses genuine tokens.
- Algorithm, decided by the API. The header names its own algorithm, and the token is the attacker's to write. In 2015, several JWT libraries were found to believe it. Say
"alg": "none"and they skipped the signature. Say"HS256"and they used the RSA public key, which anyone can download, as an HMAC secret.
Switch the API to the careless verifier and try the last two tokens again. Then pick "decode only", an API that reads the token and never checks the signature, and try any of them.
exp.Opaque vs JWT: who gets the final say
A JWT is checked by the API alone. An opaque token is a random string, and only its issuer knows what it means.
An API that receives an opaque token has to ask the auth server about it, every time. That's token introspection (RFC 7662). It costs a round trip, and it buys something JWTs can't do: the answer is current. Revoke the token and the next call fails.
A JWT says what it says until exp. The API never calls home, so it never hears that you signed out, or that the laptop with the token on it was stolen. That's why JWT access tokens live minutes, not days, and why the long-lived part (the refresh token, step 13) is always checked by the auth server itself.
Key sets, kid, and the shared-secret trap
How an API finds the right public key, and why symmetric signing quietly makes every API an issuer.
The auth server publishes its public keys as a JSON Web Key Set (JWKS) at a well-known URL. Every token's header names the key that signed it, with kid. So when the auth server rotates to a new key, an API that sees an unknown kid just fetches the set again. No config change, no deploy.
HS256 works differently. It's one shared secret, and the same secret both signs and checks. Every service that needs to check tokens has to hold it, so every one of them can also make them.
Why OAuth exists
Before OAuth, if an app needed your data on another site, you gave it your password.
A budgeting app wants to read your shop orders. The obvious way is to ask for your shop password. Now it has the password forever, it can do anything you can do, and the shop can't tell the app apart from you. If you want it gone, you change your password, which breaks everything else that uses it.
OAuth 2.0 (RFC 6749, 2012) is about delegation. You sign in at the shop's own auth server. You approve a narrow scope ("read orders"). The app gets a token that does only that, for a little while, and can be revoked without touching your password. There are four roles: you (the resource owner), the app (the client), the authorization server and the API (the resource server).
The authorization code flow
The browser carries a short-lived code. The tokens travel server-to-server, where the browser never sees them.
The flow uses two kinds of channel. The front channel is redirects through your browser. Anything sent that way can end up in history, in logs or with a browser extension. The back channel is a direct HTTPS call from the app's server to the auth server.
So the front channel only carries a code: single-use, valid for about a minute, and worthless without the app's client secret. The app trades the code for tokens on the back channel. Follow the coloured code through the rows. It goes out in a redirect, comes back in a URL, and is spent in one POST.
Two parameters guard the redirects. redirect_uri must exactly match an address the app registered, or the auth server won't send the code anywhere. state is a random value tied to your browser, so the app can tell a callback it asked for from one someone pushed onto you.
"Exactly" matters more than it looks. Some auth servers compare only the beginning, or allow wildcards, so that any page on the app's site counts. That's harmless until one page on the site forwards anywhere (an open redirect, like /out?url=…). The last case chains the two. The attacker starts the flow themselves, so PKCE (step 12) doesn't help: the verifier is theirs. And they ask for the code after the #, because a browser keeps the fragment when a redirect doesn't set one of its own. RFC 9700 requires exact string matching for this reason.
state is the defence, and it's one comparison.PKCE: proof you're the one who asked
An app that can't keep a secret makes a new one for every sign-in, and sends only its hash.
A phone app or a single-page app is a public client. Its code is on your device, so any secret in it can be pulled out. That means the code from step 11 is all anyone needs, and on a phone the code arrives through a custom link like com.example.photos:/callback, which another app can claim too.
PKCE (RFC 7636, said "pixy") fixes this with one hash. Before it starts, the app makes a random code_verifier and keeps it. It sends code_challenge = SHA-256(verifier) with the request. To redeem the code, it must show the verifier itself. A thief who catches the code has nothing to show.
Refresh tokens: short keys, long leash
Access tokens expire in minutes. A refresh token gets a new one without asking the person again.
Short-lived access tokens limit the damage when one leaks, but nobody wants to sign in every five minutes. So the auth server also hands out a refresh token. It lives for weeks, it only ever goes to the auth server (never to an API), and the auth server checks it on every use, so revoking it works at once.
For public clients the refresh token is rotated: every use returns a new one and kills the old one. If an old one ever comes back, two parties have copies, and the auth server can't tell the thief from the owner. So it revokes the whole family and makes the person sign in again.
Client credentials: no person at all
A program trades its own secret for a token. No browser, no redirects, no consent screen.
For a nightly job or a backend service there's no person to sign in. The program is the client and the subject. It posts its client id and secret to the token endpoint and gets back a short-lived access token, whose sub is the program itself.
Compared with an API key (step 3), the secret only ever goes to the auth server, and APIs only ever see tokens that expire, carry scopes, and are checked in one place.
private_key_jwt), or a client certificate over mTLS (step 25). Cloud platforms go further with managed identities, where the platform hands the workload a credential and nobody holds a secret at all.The device flow: sign in somewhere else
For TVs, game consoles and command-line tools: show a short code, and let a phone do the sign-in.
Typing a password with a TV remote is miserable, and a CLI on a server may have no browser at all. The device flow (RFC 8628) splits the job. The device asks for a pair of codes. It shows the short one to you, and polls the token endpoint with the long one. You sign in on your phone, at a page you trust, with your password manager and second factor. The device's next poll gets the tokens.
Retired flows you'll still meet
The implicit flow and the password grant are in old tutorials and older code. OAuth 2.1 removes both.
The implicit flow sent the access token itself back in the redirect URL, after the #. It was made for an era when browsers couldn't POST to another site's token endpoint. The token ends up in browser history, readable by every script on the page, with nothing to show the page that receives it is the one that asked.
The password grant (resource owner password credentials) lets an app collect your username and password and trade them for tokens. It's step 10's anti-pattern with an OAuth label: the app sees the password, and there's no room for a second factor or a consent screen.
response_type=token or grant_type=password, it's out of date.OpenID Connect: who just signed in
OAuth plus one more token. The ID token is a signed note to the app about the person, not a key to anything.
People used OAuth for sign-in anyway ("if I got a token for your profile, you must be you"), and it went wrong in creative ways. OpenID Connect (2014) makes sign-in a real feature. Add scope=openid and a nonce to the code flow, and the token response includes an ID token: a JWT addressed to the app (aud is the app's client id) saying who signed in, when, and echoing the nonce.
It also standardises discovery. Every provider publishes /.well-known/openid-configuration, listing its endpoints and keys. That's how one library can work with Google, Microsoft Entra, Okta and a self-hosted IdP.
Which claim is the user?
The ID token checked out. Now the app has to decide which account it belongs to, and the obvious answer, the email, is the wrong one.
An ID token is one identity provider's statement about a person. sub is that provider's permanent id for them, and it's only unique within that issuer, so the stable key is iss + sub. The email is a contact detail. It can change, it can be reused, and whoever runs the IdP decides what it says.
That last part bites when an app accepts more than one issuer. A multi-tenant app ("sign in with your work account") trusts every tenant of a cloud IdP, and anyone can make a tenant. In Microsoft Entra ID, an app open to any organisation (the /common endpoint) gets tokens whose iss names each tenant. In 2023 researchers found apps keying accounts on the email claim, which a tenant's admin can set to anything, and named the takeover nOAuth.
iss + sub (in Entra, the tid and oid claims). Use the email to contact a person, and to find one only when an issuer you trust vouches for it (email_verified: true). Linking a new sign-in to an existing account should take a sign-in to that account, not a matching email.Single sign-on is a cookie
You signed in to one app this morning. Why doesn't the second app ask for your password?
When you sign in at the identity provider, it sets its own session cookie, on its own domain. Every app that uses it sends you there to sign in. When you arrive with that cookie, the IdP doesn't need your password: it already knows you, and sends a code straight back. The redirects still happen, they just don't stop.
SAML: the same idea, in signed XML
The enterprise standard from 2005. The browser carries a signed XML document from the identity provider to the app.
SAML 2.0 has the same roles as OpenID Connect, with different names: the app is the service provider (SP), the IdP is still the IdP. The app redirects you with an AuthnRequest. The IdP signs you in and returns an HTML form that posts itself to the app, carrying a SAMLResponse: base64 of an XML document with a signed assertion inside. Click it in the trace and read it.
There's no back channel here: the browser carries everything, so everything must be signed and checked. The checks look familiar: the signature, the audience, the recipient, the expiry, and that it answers a request this app actually sent.
XML signatures don't sign a document. They sign one element, found by its ID. If the code that checks the signature and the code that reads the user don't look at the same element, an attacker can keep a genuine signed assertion in the document and add a forged one where the reader looks first. That's XML signature wrapping. A 2012 study (Somorovsky et al.) found it in 11 of the 14 SAML frameworks it tested.
SAMLRequest is also DEFLATE-compressed, and the signature is computed over XML after a canonicalisation step that normalises whitespace and namespaces. This page canonicalises more simply, but signs, digests and checks the same way. If you're building a service provider: use a maintained library, and read the user only from the element the library hands you after it has checked the signature.Backend-for-frontend: keep tokens out of the browser
Anything JavaScript can read, injected JavaScript can read too.
A single-page app that does OAuth in JavaScript ends up holding the access token in the browser, usually in localStorage. One cross-site scripting (XSS) bug, from a review, a dependency or an analytics snippet, and a script can read the token and post it to the attacker, who then uses it from anywhere.
A backend-for-frontend (BFF) is a small server on the same site as the app. It does the OAuth flow as a confidential client, keeps the tokens on the server, and gives the browser one HttpOnly, SameSite=Strict cookie. The page calls /api/… on its own origin, and the BFF attaches the token on the way through.
CORS: who may read the answer
CORS isn't a lock on your API. It's the browser's rule about which pages' scripts may read a response from another site.
When a page's script calls another site with fetch(), the browser sends the request, cookies included if their SameSite setting lets them travel (step 4). CORS decides something narrower: whether the script gets to read the response. The server opts in with Access-Control-Allow-Origin, naming the origin it trusts, and for a request made with cookies, Access-Control-Allow-Credentials: true as well.
The wildcard * doesn't work with cookies: browsers refuse that combination. So the day an app first needs cookies on a cross-site API, * stops working, and the quick fix is to echo back whatever Origin the request names. That works, for every site on the internet.
Vary: Origin, and keep cookies SameSite=Lax or Strict unless a page on another site truly needs them.Proxies, identity headers, and the route nobody checked
A reverse proxy does the sign-in, and the app behind it trusts a header. That's fine until a request reaches the app without the proxy checking it.
A common setup: a reverse proxy sits in front of an app and checks every request with a login service first (forward auth). If the person is signed in, the proxy adds a header like X-Auth-User: ana and passes the request on. The app never touches a password. It reads the header and trusts it, because only the proxy can reach the app.
Now add one ordinary exception. Some routes, such as webhooks or an API for bearer-token clients, skip the login check, because machines can't click through a login page. On those routes the proxy passes the request through as it is, headers included. If the app also falls back to the identity header when there's no token, an attacker can type X-Auth-User: admin and the app believes it.
On-behalf-of: one token per hop
The orders API needs the ledger API, still as Ana. Forwarding her token is wrong. Trading it is right.
Ana's token was made for the orders API (aud). If the orders API passes it on, a ledger that checks the audience refuses it, as it should. A ledger that doesn't check would accept tokens made for anything, which means any service that ever sees one of Ana's tokens can act as her everywhere.
The fix is to trade it. The orders API sends Ana's token to the auth server, along with its own credentials, and asks for a token for the ledger. The auth server checks that the token really was made for the orders API, and that an admin has allowed the orders API to call the ledger for its users. Then it issues a new token: same sub, new aud, plus a record of who is acting.
act claim. Microsoft Entra ID's on-behalf-of flow is the same trade with older wiring: grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the incoming token as assertion, requested_token_use=on_behalf_of, and the downstream API named by scope=…/.default. If you work in Azure, this is the one you'll write.Mutual TLS: both ends show a certificate
Ordinary TLS proves the server is who it says. mTLS makes the client prove it too, below HTTP, before a single request is sent.
In every HTTPS connection the server presents a certificate, and proves it holds the matching private key by signing the handshake. With mutual TLS the server sends a CertificateRequest, and the client has to do the same thing: present a certificate from a CA the server trusts, then sign the handshake with its private key (CertificateVerify).
That second part is what makes it strong. A certificate is public; the client shows it to every server it talks to. Copying one gets you nothing, because you can't produce the signature. And it happens on every connection, so there's nothing to replay.
The last case joins this to bearer tokens. A token can name the certificate it belongs to (cnf, RFC 8705). Then a stolen token is useless over any other connection.
Signed requests: proof in every message
For webhooks there's no token to send. The request itself carries a signature over its exact bytes.
When a payment provider tells your app "order 102 was paid", your endpoint is public, and anyone can post to it. So the provider and you share a secret, and every webhook carries an HMAC of the timestamp and the raw body, made with that secret. Your app computes the same HMAC and compares. Change one byte of the body and the HMAC changes completely. The timestamp is inside the signature, so an old captured request can't be made to look new.
All at once: an AI app signs in to an MCP server
You add a self-hosted MCP server to an AI chat app, a window pops up, you sign in, and it works. Here's everything that happened.
This is the shape of a real self-hosted MCP server, with the names changed. The MCP server is its own OAuth authorization server, but it doesn't keep passwords. It hands sign-in to an identity provider with OpenID Connect, then issues its own opaque tokens. Nearly every step on this page shows up:
- The AI app calls
/mcpwith no token. The 401'sWWW-Authenticateheader points at the server's protected resource metadata (RFC 9728), which names its authorization server. - The app reads the authorization server metadata (RFC 8414) and registers itself (dynamic client registration, RFC 7591). Nobody configured this client in advance. Registering proves nothing; a person still has to sign in and agree.
- Flow #1: the app makes a PKCE verifier and state and opens a window at the MCP server's
/authorize, naming the resource it wants a token for (resource, RFC 8707). - Flow #2, inside flow #1: the MCP server parks that request and starts an OpenID Connect flow of its own with the identity provider. It makes its own PKCE verifier, state and nonce.
- You sign in at the identity provider: the only password on the page. The IdP sends a code back to the MCP server, which redeems it, fetches the IdP's keys and checks the ID token, nonce included.
- Now the MCP server finishes flow #1. It sends its own code back to the AI app, which redeems it with its verifier and client secret for an opaque access token (good for 7 days) and a refresh token (30 days).
/mcp, not the site root, because a client that checks its token against the root lands on whatever is there (often a login page for people), and loops. And the MCP server's routes skip the proxy's login check so that machines can reach them, which is exactly the setup from step 23. That route must never trust an identity header.Which one do I want?
Start from the situation, not the acronym.
| You have | Reach for |
|---|---|
| A script or cron job calling one API | An API key, or client credentials if you already run an auth server |
| A server-rendered web app signing people in | OpenID Connect, code flow, as a confidential client; then a session cookie |
| A single-page app | A backend-for-frontend. If you can't, code flow + PKCE with tokens kept in memory |
| A phone or desktop app | Code flow + PKCE, in the system browser |
| A TV, a CLI, anything without a good keyboard | Device flow |
| A service calling a service, no user | Client credentials; mTLS if it matters |
| A service calling a service for a user | Token exchange / on-behalf-of |
| Enterprise customers who want their own SSO | Whatever their IdP speaks: OIDC, or (often) SAML |
| Webhooks you receive | An HMAC signature with a timestamp, checked on the raw body |
| High-value machine-to-machine traffic | mTLS, plus tokens bound to the certificate |
| An AI app connecting to your MCP server | OAuth with discovery, dynamic registration and PKCE |
And the rules underneath every row:
- Short-lived tokens, checked every time, for exactly one audience.
- Knowing who's asking isn't permission. Check every object against the caller, on every request.
- A user is
iss+sub. An email is a contact detail. - The verifier decides how a token is checked. The token never does.
- Secrets stay where they can't be copied: out of URLs, logs and browser storage.
- Whatever crosses the browser can be seen, replayed or swapped, so it's either single-use (a code), tied to the browser (state, nonce, PKCE), or signed.
A token is a claim someone signed. Auth is deciding which signatures to believe, and for how long.