---
title: "API Keys, OAuth and JWT: Three Answers to Different Questions"
pubDate: 2026-10-18T00:00:00.000Z
tags:
  - Architecture
  - Security
  - AI
---

import AuthMap from '../../components/AuthMap.astro';
import AuthFigure from '../../components/AuthFigure.astro';

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

<AuthMap preset="overview" />

*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

<AuthMap preset="api-key" />

The ticketing system's admin generates a key in its settings page and gives it to us. From then on, every request carries it:

```http
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

<AuthMap preset="client-credentials" />

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

<AuthFigure name="client-credentials" />

*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:

```http
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):

```json
{
  "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_in` runs out, and fetch a new one on a `401`.
- **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

<AuthMap preset="auth-code" />

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.

<AuthFigure name="authorization-code" />

*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.
- **`state` and PKCE ride along in the redirect (1).** `state` is 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 random `code_verifier` up 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](https://datatracker.ietf.org/doc/html/rfc9700)) 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

<AuthMap preset="jwt" />

The CRM's access token starts with `eyJ`, base64 for `{"`: it is a **JSON Web Token**, three base64url parts separated by dots.

<AuthFigure name="jwt" />

*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 `exp` the 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](https://datatracker.ietf.org/doc/html/rfc7662)), 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`: the `scope` or `aud` is usually wrong.

### Checking one

If you are the API receiving a JWT, verifying it is more than checking the signature. With [PyJWT](https://pyjwt.readthedocs.io/):

```python
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"]},
    )
```

- **`algorithms`** pins the algorithm. Early libraries trusted the header, so an attacker could set `"alg": "none"` and drop the signature, or switch `RS256` to `HS256` and sign with the *public* key as if it were a shared secret.
- **`audience`** stops a token minted for the ticketing API from being replayed against the CRM.
- **`require`** rejects tokens that leave `exp` out and would otherwise never expire.

### Where else JWTs show up

- **As the client's credential.** Some providers replace the `client_secret` with a JWT the client signs with its own private key ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523)). 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

<AuthMap preset="errors" />

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

<AuthMap preset="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](/post/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](/post/workflow-vs-agent) argues for the order of operations.

## Reading a new API's auth docs: my checklist

1. **Which credential does it want?** An API key, an OAuth access token, or something else: HTTP Basic, mutual TLS, a signed request.
2. **If OAuth, which grant?** Whose data is it: a person's (authorization code), or does the integration act as itself (client credentials)?
3. **Where is the token endpoint, and how does the client authenticate there?** The provider's `/.well-known/openid-configuration` or `/.well-known/oauth-authorization-server` lists it.
4. **Which scopes, at minimum?** Ask for those and no more.
5. **How long do tokens live, and do refresh tokens rotate?** That decides the caching and storage code.
6. **What do a `401` and a `403` look like here?** Some APIs answer `400` with an error body instead.
7. **Who rotates which secret, and how?** Write it down before go-live, not after the first expiry.

## Sources

- [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749): the OAuth 2.0 framework, with the grant types.
- [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750): bearer tokens, the `Authorization: Bearer` header and the `401`/`403` error codes.
- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636): PKCE.
- [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700): current OAuth security best practice.
- [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519): JSON Web Token, and [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068): JWTs as OAuth access tokens.
- [RFC 8725](https://datatracker.ietf.org/doc/html/rfc8725): JWT best practices, including the algorithm attacks.
- [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html): ID tokens.
