Your API Auth Choice Isn't OAuth vs API Keys vs JWT — It's Two Questions
A partner's integration page once gave me three radio buttons: OAuth 2.0, API key, JWT. I picked one, shipped it, and spent the next year discovering that the form had asked three unrelated questions and forced a single answer to all of them. The thing I selected as an "API key" turned out to be a JWT their platform signed with no expiry, which meant I had chosen a credential, a format and a validation strategy in one click, without being told that is what I was doing.
That dropdown is everywhere, and it teaches a false trichotomy. OAuth 2.0 is a protocol for obtaining a token. An API key is a credential you already hold. A JWT is a way of encoding a token's contents. They sit on three different axes, they compose freely, and once you see that, the actual decision collapses into two questions that have nothing to do with the brand names.
| What it actually is | What it does not tell you | |
|---|---|---|
| OAuth 2.0 / 2.1 | a protocol for how a token is obtained and refreshed | what the token looks like, or how you validate it |
| API key | a long-lived bearer credential, issued out of band | how it is scoped, rotated, or transmitted |
| JWT | a signed, self-describing token format | how it was obtained, or whether it can be revoked |
You can run OAuth and issue opaque tokens. You can hand out an API key that is internally a JWT. You can validate a JWT by calling an introspection endpoint and throwing away the entire point of the format. None of these combinations is exotic; I have shipped all three.
Question one: who is the caller, and on whose behalf?
This decides the protocol, and it is the only question where "use OAuth" is ever the right answer on its own.
Your own backend calling your own API. There is no delegation happening. Nobody is granting anybody permission to anything — you are authenticating a service you operate. An API key or the OAuth client credentials grant both work; the key is simpler and the grant gives you expiry and rotation for free. Choose on operational taste, not security theatre.
A third-party application acting for a user. This is the case OAuth was invented for in RFC 6749 back in October 2012, and the only correct answer is the authorization code flow with PKCE. Not an API key the user pastes in, not a password your integration stores. OAuth 2.1 — still a late-stage IETF draft as of mid-2026, at draft-ietf-oauth-v2-1-15, not yet a published RFC despite how universally it is cited — consolidates this by making PKCE mandatory for every authorization code client and deleting the implicit and password grants outright. Treat the draft as settled: every major authorization server already behaves this way.
An agent acting for a user across services it did not write. This is the 2026 case, it is genuinely new, and it breaks the API key model completely. More on it below, because it is where the interesting engineering now lives.
Question two: can you afford to phone home on every request?
This decides the format, and it is the trade-off that quietly determines your incident response.
A self-contained JWT is validated by checking a signature. No network call, no shared state, no dependency on the issuer being up. It also means the token is valid until it expires, and there is nothing you can do about it — a signature check cannot know that you fired someone ninety seconds ago.
An opaque token is a random string the resource server exchanges for its meaning, either through a lookup or via token introspection (RFC 7662). Every request costs a round trip or a cache hit, and in return revocation is instant and complete.
Almost everyone resolves this the same way, and the resolution is correct: short-lived JWT access tokens plus a long-lived refresh token that is opaque and revocable. Your worst-case exposure after a revocation becomes the access token lifetime, so that number is a security parameter, not a convenience setting.
| Access token TTL | Exposure after revoking | Introspection load |
|---|---|---|
| 5 minutes | ≤ 5 min | none |
| 1 hour | ≤ 1 hour | none |
| 24 hours | ≤ 24 hours | none |
| opaque + introspection | immediate | every request |
Fifteen minutes is the number I default to. If your answer to "how long may a compromised token keep working?" is longer than your incident response time, the design is wrong regardless of which RFCs it cites. And if you are issuing JWTs as access tokens, RFC 9068 (October 2021) specifies the profile — required iss, aud, exp, sub, and the at+jwt type header — which exists precisely because everyone was inventing incompatible claim sets.
An API key is a bearer token with no specification behind it
There is no RFC for API keys. That is not pedantry, it is the source of every problem they cause: with no standard, there is no agreed answer for where the key goes, how it is scoped, when it expires, or what rotation looks like. Each vendor improvises, and the improvisations leak.
The one that keeps happening is the key in a query string. ?api_key=... puts a live credential into access logs on every hop, into Referer headers on outbound links, into CDN cache keys, and into any error-reporting tool that captures full URLs — the same class of mistake as pasting a live token into an online decoder, except automated and continuous. Keys belong in an Authorization header, always.
If you must ship long-lived credentials to machines you do not control, the upgrade that costs least is request signing rather than bearer keys: the client signs the method, path, body hash and a timestamp with a shared secret, and sends the signature instead of the secret. A replayed request fails once the timestamp is outside the window, and a leaked log line contains a signature that is worthless on any other request. That is HMAC used exactly as it was designed, and if you are building the canonical string for the first time, checking your implementation against an HMAC generator before debugging both ends at once will save you an afternoon.
Whatever you choose, an API key needs the three properties the format does not give you: a scope narrower than "everything this account can do", an expiry, and a rotation path that does not require downtime. Two keys valid at once is the whole trick.
Agents made audience binding non-negotiable
Here is the failure that the old model cannot express. An agent holds a credential to reach service A. Service A's operator, or anyone who can influence the agent, points it at service B. If the credential is a plain bearer token — an API key, or a JWT with no audience check — it works at B too. That is the confused deputy problem, and it stopped being theoretical the moment assistants started holding tokens for a dozen tools at once.
The Model Context Protocol's authorization spec is the clearest published answer, and it is worth reading even if you never ship an MCP server, because it is a compact statement of what token hygiene now requires:
- an MCP server is an OAuth 2.1 resource server, not an authorization server — a separation most homegrown integrations blur;
- it must publish
/.well-known/oauth-protected-resourceper RFC 9728 (April 2025) so a client can discover which authorization server to talk to, rather than being configured by hand; - clients must send the RFC 8707
resourceparameter in both the authorization and token requests, whether or not the server appears to support it — this is what stops a token minted for one server being spent at another; - the November 2025 revision banned plain PKCE in favour of
S256only, and required401responses to carry aWWW-Authenticateheader pointing at the authorization server; - the 2026-07-28 revision deprecated Dynamic Client Registration in favour of Client ID Metadata Documents, keeping DCR available for at least twelve months.
The through-line is that every token now has to name what it is for. Validating a signature and an expiry is not validating a token; if you are not checking aud against your own identifier, you are accepting anything your issuer ever signed. That obligation lands on the resource server, which is the part teams skip when they wire up tool servers quickly — the same operational gap I flagged in what MCP solves and where it still hurts.
Two mechanisms are worth knowing for anything sensitive: mTLS-bound tokens (RFC 8705) and DPoP (RFC 9449, September 2023), both of which bind a token to a key the client proves possession of, so a stolen token is inert without the matching private key. They are the real answer to bearer tokens; they are also genuine work, so reach for them when the token grants something you would not want replayed.
The decision, compressed
| Situation | Protocol | Format | Validation |
|---|---|---|---|
| Your backend → your API | API key or client credentials | either | shared secret or signature |
| Third-party app → user's data | authorization code + PKCE | JWT access, opaque refresh | signature + aud |
| Agent → many tools on a user's behalf | OAuth 2.1 with resource |
JWT, audience-bound | signature + aud + iss |
| Anything you must revoke instantly | any | opaque | introspection |
| Public client (SPA, mobile, CLI) | authorization code + PKCE, no secret | JWT access | signature + aud |
The JWT column carries its own set of ways to get it wrong — algorithm confusion, unvalidated iss, accepting none, forgetting that Base64 is not encryption — which I have written up separately in the JWT structure and security pitfalls deep dive. When you need to look inside a token during debugging, do it with a JWT decoder that runs locally rather than posting a live credential to someone's server.
Answer the Two Questions First
Write down who the caller is and whether you can afford to phone home, before anyone says the word OAuth. Those two answers pick the protocol and the format, and the arguments about brand names stop happening.
Then check the one thing that survives every architecture in this article: your resource server validates aud against its own identifier, on every request, and rejects tokens that do not name it. A signature proves who minted the token. Only the audience proves it was minted for you.