Skip to main content
Architecture Security AI
View as Markdown Suggest changes

API Keys, OAuth and JWT: Three Answers to Different Questions

· Reading time: 11 min

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.

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorLLMOur codeTheir API

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 isWhat it answersLifetime
API keyA credential”This request comes from application X”Months, until someone rotates it
OAuth 2.0A protocol for getting credentials”Application X may do these things, as itself or for user Y”Access token: minutes to an hour
JWTA 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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorStaticLLMOur codeTheir API

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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorOne hourLLMOur codeTheir API

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.

ToolsAuth serverCRM APIClient ID + secret1Access token, 1 h2Bearer access token3Checks the JWT200: Alex’s account4Reuse the token for the hour

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_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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorConsentLLMOur codeTheir API

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.

AdvisorAuth serverOur backendCalendarRedirect: scope, state, PKCE1Log in, approve2Code, via browser3Code + verifier4Access + refresh5Bearer access token6An hour later, without the advisorRefresh token7New pair8Store the new refresh token

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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorSelf-checkingLLMOur codeTheir API

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

eyJhbGci….eyJpc3Mi….Kq3xT9…HeaderWhich key signed italgRS256kidcrm-2026-10PayloadReadable by anyoneissauth.crm.example.comsubsupport-agentaudapi.crm.example.comscopecontacts.read cases.writeexpin one hourSignatureAuth server’s private keyoverheader.payloadSigned, not encrypted

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), 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:

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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorRetry onceLLMOur codeTheir API

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

Tool call, no secretSupport agentPicks a tool and its argumentsToolsHold the secretsAttach one to each callTicketing systemAPI key on every callValid until rotatedCRMAuth server: secret → tokenAPI: checks the JWT itselfAdvisor’s calendarAdvisor consents onceRefresh token per advisorNo secretsLLMOur codeTheir API

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

  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

AI Chat

Messages you send are processed by the Google Gemini API to generate responses. Do not share sensitive personal data. See the privacy policy for details.