// ~/playground/auth

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.

$ curl -H "Authorization: Bearer $TOKEN" https://api.example/orders # edit the token; break it; watch the API decide
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.

0

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.

1

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.

Note: Basic auth is still fine for a script talking to an internal tool over TLS. Its problems are everything else: the password goes to every server you call, there's no expiry and no scope, and nobody can sign in with a second factor. Everything after this step is some way to avoid sending the password.
2

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.

Note: No token format fixes this, and nothing later on this page does either. The best OAuth setup in the world only tells the API who is asking; what that person may touch is the API's own business. Put the owner in the query (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.
3

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.

Note: Keys never expire on their own, so plan for rotation from day one: let each caller have two keys at once, so it can switch to the new one before you revoke the old. A key also can't say which person is calling. For that you need something a person signs in to get, which is where tokens come in.
4

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.

Note: Look at the last case. Signing out deletes the row, and a copied cookie stops working at once. Sessions on the server can always be killed. Keep that in mind for step 8, where a JWT can't be.
5

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.

Note: "Bearer" is how a token is presented, not what it is. The token in that header could be a JWT or an opaque string. Tokens that need more than possession, bound to a key the holder must prove it has, come in step 25.
6

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.

Note: RS256 means the auth server signs with an RSA private key that never leaves it, and anyone can check with the matching public key. That's why an API can check a JWT by itself, without asking the auth server, and that's the whole reason JWTs exist.
7

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.

Note: The fix for the algorithm attacks is one line in every library: pass the list of algorithms you accept, and never let the token choose. The bench at the top of the page runs the same checks: edit the payload, sign it a different way, move the clock past exp.
8

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.

Note: Neither is "more secure". JWTs scale better and survive the auth server being down. Opaque tokens can be killed instantly and reveal nothing if copied. Plenty of systems use both: opaque to the outside world, swapped for a JWT at the edge.
9

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.

Note: HS256 is fine when one party both makes and checks the tokens (a server signing its own session cookies, say). Once a second service has to check them, switch to an asymmetric algorithm: RS256, or ES256, which does the same with much smaller keys.
10

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).

Note: OAuth is about authorization: what the app may do. It doesn't tell the app who you are. That sounds like a technicality until people use it for sign-in anyway, which is why OpenID Connect (step 17) exists.
11

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.

Note: The pushed-callback attack is subtle. It doesn't steal your account. It signs you in to the attacker's account, so the card details or documents you save go to them. Checking state is the defence, and it's one comparison.
12

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.

Note: PKCE started as a patch for phone apps. Current guidance (the OAuth 2.0 Security Best Current Practice, RFC 9700, and the OAuth 2.1 draft) says to use it for every client, confidential ones included, because it also stops codes from being injected. There's no good reason to leave it out.
13

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.

Note: A confidential client (one with a server and a client secret) gets a quieter protection: its refresh token is useless without the client secret. Rotation matters most for apps in browsers and on phones, which have no secret to fall back on.
14

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.

Note: The secret can be swapped for something stronger without changing the flow: a signed JWT the client makes with its own private key (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.
15

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.

Note: The weak spot is the code itself. An attacker can start a device flow and send you the code with "enter this to verify your account", which is a known phishing technique. Good auth servers show which app and device you're approving, and keep the codes short-lived.
16

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.

Note: The replacement for both is the same: authorization code with PKCE. If a library or guide reaches for response_type=token or grant_type=password, it's out of date.
17

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.

Note: Two tokens, two readers. The ID token is for the app: read it once, check it (signature, issuer, audience, expiry, nonce), and sign the person in. The access token is for the API. Sending an ID token to an API, or accepting one that was made out to a different app, are both common bugs.
18

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.

Note: Key accounts on 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.
19

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.

Note: This is why "sign out" is hard. Each app has its own session, and so does the IdP. Signing out of one app usually only ends that app's session. Signing out everywhere needs the IdP to end its session and tell every app (OpenID Connect has front-channel and back-channel logout for this).
20

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.

Note: In real SAML, the redirect's 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.
21

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.

Note: A BFF doesn't make XSS harmless. While the page is open, an injected script can still make requests through the BFF, because the browser attaches the cookie. What it takes away is the token: the attacker can't carry it off and use it after you close the tab. Current guidance for browser apps (the IETF's draft on OAuth for browser-based apps) puts the BFF first for this reason.
22

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.

Note: CORS never stops a request from arriving. A forged POST still lands and still runs (that's CSRF, step 4); CORS only hides the answer, and simple GETs and form posts don't even get a preflight. Keep an explicit allow-list, send Vary: Origin, and keep cookies SameSite=Lax or Strict unless a page on another site truly needs them.
23

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.

Note: Neither decision is wrong on its own, which is why this bug is so common. The rule that fixes it: the proxy removes identity headers from every incoming request, on every route, before it decides anything. Only the proxy should ever be able to set them. On top of that, the app should accept the header only on routes where the proxy checks.
24

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.

Note: The standard is OAuth 2.0 Token Exchange (RFC 8693), and it records the middle service in an 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.
25

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.

Note: mTLS is common between services, inside service meshes (which hand every workload a certificate and rotate it automatically) and in banking APIs. It's rare for people in browsers, because managing client certificates on laptops is miserable. DPoP (RFC 9449) gets the same "prove you hold the key" property at the HTTP layer, with a key the app makes itself. The certificates here are simplified: real ones are ASN.1/DER, not JSON, but they're signed and checked the same way.
26

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.

Note: Check the signature over the raw body, before any framework parses and re-serialises it, and compare in constant time. AWS signs every API call this way too (Signature Version 4): an HMAC over the method, path, headers and a hash of the body, with a key derived from your secret, the date and the region.
27

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:

  1. The AI app calls /mcp with no token. The 401's WWW-Authenticate header points at the server's protected resource metadata (RFC 9728), which names its authorization server.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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).
Note: Two details in this design were learned the hard way. The resource metadata names /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.
28

Which one do I want?

Start from the situation, not the acronym.

You haveReach for
A script or cron job calling one APIAn API key, or client credentials if you already run an auth server
A server-rendered web app signing people inOpenID Connect, code flow, as a confidential client; then a session cookie
A single-page appA backend-for-frontend. If you can't, code flow + PKCE with tokens kept in memory
A phone or desktop appCode flow + PKCE, in the system browser
A TV, a CLI, anything without a good keyboardDevice flow
A service calling a service, no userClient credentials; mTLS if it matters
A service calling a service for a userToken exchange / on-behalf-of
Enterprise customers who want their own SSOWhatever their IdP speaks: OIDC, or (often) SAML
Webhooks you receiveAn HMAC signature with a timestamp, checked on the raw body
High-value machine-to-machine trafficmTLS, plus tokens bound to the certificate
An AI app connecting to your MCP serverOAuth 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.

$ cd .. → more interactives