API Keys, OAuth and JWT: Three Answers to Different Questions
TL;DR
“API keys, OAuth or JWT?” sounds like a choice between three options, but they sit on different layers. An API key is a credential: a long-lived secret you send with every request. OAuth 2.0 is a protocol for getting a credential: you trade something you hold for a short-lived, scoped access token, either as yourself (client credentials) or on behalf of a person who consented (authorization code). A JWT is a format: a signed JSON token the API can check on its own, without a database call. An OAuth access token is often a JWT, which is why the three get mixed up. As the one calling an API, I mostly need to know which credential the API wants, how to get and refresh it, and how to keep it out of places it doesn’t belong, including the model’s context.
The running example as one map: an agent that calls three systems, each authenticated its own way. Each section’s map highlights its part.
Three answers to different questions
Every authenticated API call answers some version of three questions: who is calling, on whose behalf, and how does the API know. The three terms answer different ones.
| What it is | What it answers | Lifetime | |
|---|---|---|---|
| API key | A credential | ”This request comes from application X” | Months, until someone rotates it |
| OAuth 2.0 | A protocol for getting credentials | ”Application X may do these things, as itself or for user Y” | Access token: minutes to an hour |
| JWT | A token format | ”Here’s who issued this, for whom, until when, and a signature proving it” | Whatever its exp claim says |
So the honest comparison is not API key vs OAuth vs JWT. It is static secret vs short-lived token, and, for tokens, opaque string vs self-contained JWT.
The running example is the invented Northstar Bank from my posts on support automation. Alex Carter writes in that their account is locked, and to help, the support agent calls three systems: a ticketing system to open an escalation, the CRM to look up Alex’s account and its security flag, and the calendar of Alex’s personal advisor to book a callback. Same agent, three integrations, three patterns.
API keys: one secret, one header
The ticketing system’s admin generates a key in its settings page and gives it to us. From then on, every request carries it:
POST /v2/tickets HTTP/1.1
Host: api.ticketing.example.com
Authorization: Bearer tk_live_4f9a...e21c
Content-Type: application/json
{"subject": "Locked account, security flag set", "customer_id": "NSB01234", "priority": "high"}
Some APIs want it in a custom header (X-API-Key) or as the username of HTTP Basic auth; the docs say which. The server looks the key up and lets the request through. Nothing to fetch, nothing expires mid-conversation, and a curl with one header works. The costs come from the same simplicity:
- It identifies an application, not a person. The ticket is created “by the integration”, whoever the agent is talking to.
- It lives until someone rotates it. A key leaked into a log or a Git commit keeps working until someone notices.
- It is often all-or-nothing. Some platforms let you scope a key to read-only or one project; many don’t.
- It goes in a header, never in the URL. Query strings end up in proxy logs and browser history.
A fine fit when the calling application is the actor, the key lives in a secrets manager, and the API offers nothing better.
OAuth 2.0: trade a secret for a short-lived token
OAuth fixes the long-lived part with a middleman, the authorization server. The client, our agent’s tools, sends its long-lived secret only there, once in a while, and gets back an access token: valid for minutes to an hour, and limited to the scopes that were granted, like contacts.read. The API, the resource server, only ever sees the access token. If it leaks, it is useless within the hour.
How the client gets the token is the grant type. Two cover almost every integration.
Client credentials: the agent as itself
The CRM integration has no human in it: the agent reads accounts as itself. The CRM admin registers our agent as a client and gives us a client_id and a client_secret.
Two calls to get going: one to the auth server for a token, one to the API with it. After that, only the second, until the token expires.
The token request (1) is a form-encoded POST, with the client authenticating via HTTP Basic:
POST /oauth/token HTTP/1.1
Host: auth.crm.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=contacts.read%20cases.write
The answer (2):
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNybS0yMDI2LTEwIn0.eyJpc3Mi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "contacts.read cases.write"
}
Every CRM call (3) then sends Authorization: Bearer <access_token>. Two things are easy to get wrong:
- Cache the token. A fresh one per call doubles the latency, and auth servers rate-limit the token endpoint. Keep it until shortly before
expires_inruns out, and fetch a new one on a401. - No refresh token here. The agent still has its client secret, so it just asks again.
The client_secret is long-lived too, so this can look like an API key with extra steps. What OAuth buys: the secret travels to one endpoint occasionally rather than on every call, what does travel expires, and it carries scopes.
Authorization code: the agent on someone’s behalf
The calendar belongs to the advisor. The agent shouldn’t hold a key to every advisor’s calendar; it should act for the advisors who agreed to it, on their calendars only. This delegation is what OAuth was built for, and the flow behind every “Allow this app to…” screen: the authorization code grant.
The advisor is in the loop once, for steps 1 to 3. Everything after happens between our backend and the auth server.
- The advisor logs in at the calendar provider (2), never at our app. We never see their password.
- The code (3) is worth nothing on its own. Our backend trades it (4) together with its own client credentials, so a code intercepted in the browser can’t be redeemed by someone else.
stateand PKCE ride along in the redirect (1).stateis a random value the backend checks when the code comes back, so an attacker can’t splice their code into someone else’s session. PKCE sends the hash of a randomcode_verifierup front and the verifier itself in step 4, so only whoever started the flow can finish it. Current best practice asks for PKCE on every client.- The refresh token is the long-lived secret here (5). It goes in the secrets store, one per advisor. Many providers rotate it: every refresh (7, 8) returns a new one and invalidates the old. An integration that forgets to store the new one works for exactly one hour.
Two more grants are in the original spec, and current best practice (RFC 9700) says not to use them: the implicit grant, which returned the token in the URL, and the password grant, where the client collects the user’s password itself.
JWT: a token the API can check on its own
The CRM’s access token starts with eyJ, base64 for {": it is a JSON Web Token, three base64url parts separated by dots.
The CRM’s access token, decoded. The claims are what the API checks; the signature is why it can trust them.
The payload’s fields are claims: iss who issued it, sub who it is about (our agent here, a user in a delegated flow), aud which API it is for, exp when it expires, scope what it allows.
The signature is what JWTs buy. The CRM API fetches the auth server’s public keys once, as a JWKS (JSON Web Key Set) at a well-known URL, picks the one named by kid, and verifies the token without asking anyone. No lookup per request.
- Signed, not encrypted. Anyone holding the token can read the claims. No secrets go in them, and until
expthe token is as good as a password to whoever holds it. - Can’t be taken back. A leaked JWT stays valid until
exp. That is why access tokens are short-lived and the refresh token, which the auth server does check on every use, carries the long-lived trust. An opaque token, a random string the API checks by asking the auth server (introspection), is revocable at once, at the cost of a network call per request. - Opaque to the caller. The agent calling the CRM doesn’t need to decode its token: it is addressed to the API, and the provider may change its format. Decoding it is still the fastest way to debug a
403: thescopeoraudis usually wrong.
Checking one
If you are the API receiving a JWT, verifying it is more than checking the signature. With PyJWT:
import jwt # PyJWT
jwks = jwt.PyJWKClient("https://auth.crm.example.com/.well-known/jwks.json")
def verify(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token) # picks the key by "kid"
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # never trust the token's own "alg"
audience="https://api.crm.example.com", # minted for this API, not another one
issuer="https://auth.crm.example.com", # by the server we trust
leeway=30, # tolerate small clock skew
options={"require": ["exp", "iss", "aud"]},
)
algorithmspins the algorithm. Early libraries trusted the header, so an attacker could set"alg": "none"and drop the signature, or switchRS256toHS256and sign with the public key as if it were a shared secret.audiencestops a token minted for the ticketing API from being replayed against the CRM.requirerejects tokens that leaveexpout and would otherwise never expire.
Where else JWTs show up
- As the client’s credential. Some providers replace the
client_secretwith a JWT the client signs with its own private key (RFC 7523). Service accounts on large cloud and CRM platforms work like this: nothing reusable travels at all. - As an ID token. OpenID Connect adds login on top of OAuth: the auth server also returns an ID token, a JWT that tells the client who the user is. The access token is for the API, the ID token for the client; sending an ID token to an API is a classic mix-up.
When a call fails: 401 or 403
The status code says which layer failed:
401 Unauthorized: “I don’t know who you are.” The token is missing, expired, malformed or signed by someone the API doesn’t trust. Fetch a new one and retry once.403 Forbidden: “I know who you are, and you may not do this.” A missing scope or role. A new token with the same grants won’t help; the fix is in the client’s configuration, usually on the provider’s side.
Retrying a 403 in a loop is a bug.
Credentials stay out of the model
An LLM agent adds a rule none of these specs mention: the model never sees a credential. It picks a tool and its arguments, like lookup_account(account_number="NSB01234"); the tool’s code takes the cached token from the secrets store, attaches the header and makes the call. Nothing the model could repeat, log or be talked out of by a prompt injection enters its context.
The same split decides whose permissions apply. Alex never logs in to the CRM, so the CRM can’t check what Alex may see: it sees the agent’s identity with all its scopes. Whether this caller may hear about this account is a check the tool code makes before the call, in code rather than in the prompt, as Workflow or Agent argues for the order of operations.
Reading a new API’s auth docs: my checklist
- Which credential does it want? An API key, an OAuth access token, or something else: HTTP Basic, mutual TLS, a signed request.
- If OAuth, which grant? Whose data is it: a person’s (authorization code), or does the integration act as itself (client credentials)?
- Where is the token endpoint, and how does the client authenticate there? The provider’s
/.well-known/openid-configurationor/.well-known/oauth-authorization-serverlists it. - Which scopes, at minimum? Ask for those and no more.
- How long do tokens live, and do refresh tokens rotate? That decides the caching and storage code.
- What do a
401and a403look like here? Some APIs answer400with an error body instead. - Who rotates which secret, and how? Write it down before go-live, not after the first expiry.
Sources
- RFC 6749: the OAuth 2.0 framework, with the grant types.
- RFC 6750: bearer tokens, the
Authorization: Bearerheader and the401/403error codes. - RFC 7636: PKCE.
- RFC 9700: current OAuth security best practice.
- RFC 7519: JSON Web Token, and RFC 9068: JWTs as OAuth access tokens.
- RFC 8725: JWT best practices, including the algorithm attacks.
- OpenID Connect Core: ID tokens.