10 / 11
Protocols / 10

Authentication

There are five authentication schemes you'll encounter on most HTTP APIs: simple API keys, OAuth 2.0 with PKCE, signed JWTs as bearer tokens, mutual TLS, and HMAC-signed requests. None of them is the obvious right choice for everything. The interesting question is usually who's calling the API, what trust you have in their environment, and how much rotation discipline they can sustain.


Authentication is not authorization

Two words sit close together and get muddled all the time. Authentication answers who is calling. Authorization answers what they are allowed to do. A request can be authenticated and still rejected: the server knows exactly who you are and decides you may not delete that resource. The reverse never makes sense, since you cannot decide what a caller may do until you know who the caller is.

Most of this page is about authentication, because that is where the protocol choices live. Authorization tends to be application logic on top: a role check, an ownership check, a scope lookup. But the two are connected through the credential. An API key, a bearer token, a client certificate — each one both proves identity and, in most designs, carries or points to the set of permissions that identity holds. Get the authentication wrong and authorization is built on sand, because the server is reasoning about a caller it has misidentified.

Keep the split in mind as you read. When a scheme is described as "coarse" it usually means the credential maps to one broad set of permissions and cannot easily express finer authorization. When a scheme is described as "scoped" it means the credential itself narrows what the caller may do. That difference is the real reason you reach for OAuth over a plain API key, or a signed JWT over an opaque string.

request+ credentialauthenticationwho is this?authorizationmay they do it?handlerestablish identity firstthen decide the action
Identity comes first, the permission decision second. The credential on the request feeds both.

The bearer token request, step by step

Most API authentication ends up looking the same on the wire: the client puts a credential in the Authorization header and the server checks it before running the handler. The word "Bearer" in Authorization: Bearer <token> is doing real work — it means whoever holds the token is treated as the caller, with no further proof of possession. That is the strength and the weakness in one line. It is simple, and a stolen token is as good as the original until it expires.

clientAPI serverGET /v1/charges   Authorization: Bearer abc.def.ghivalidate tokensignature · expiry · audiencescope · revocation?200 OK   (or 401 if the token fails any check)whoever holds the token is the caller
The server's whole job on each request is the validate box: prove the token is real, current, meant for this API, and not revoked.

Everything inside the validate box is where the schemes differ. An API key validation is a table lookup. A signed JWT validation is a signature check plus a handful of claim comparisons, usually with no database at all. An opaque OAuth token validation is a call to an introspection endpoint. mTLS does its checking during the TLS handshake, before any HTTP is parsed. HMAC validation recomputes a signature over the whole request. Same header position on the wire, very different work behind it, and very different failure modes.

API keys

A long random opaque string. The client sends it on every request; the server looks it up. That's the whole scheme.

GET /v1/charges
Authorization: Bearer sk_live_51Hg82C2eZvKYlo2C0yX8...

Use API keys when:

  • The caller is a server you control or a trusted partner.
  • You need a low-friction first-time experience.
  • The action authority maps cleanly to the key (per-environment, per-integration).

The things to get right are mostly storage discipline. Hash the key in the database (argon2 or bcrypt), not plaintext. Show the key once at creation and never again. Support rotation with overlapping validity. Scan logs and source-code repositories for accidentally-leaked keys; GitHub's secret scanning catches a lot of these.

A small design choice saves a lot of pain later: give the key a visible prefix that names what it is, like sk_live_ for a live secret key or pk_test_ for a test publishable one. The prefix lets your own secret scanners and other people's find leaked keys by pattern, and it tells an on-call engineer at a glance whether the key they are staring at in a log is a production secret. Store a hash of the full key and keep the first few characters in plaintext so the dashboard can show sk_live_51Hg… without ever holding the secret.

Rotation is the part teams skip and regret. A key with no rotation path is a key you can never safely revoke, because revoking it takes the integration down. The fix is to allow two valid keys at once: issue the new key, let the caller deploy it, watch the old key's traffic fall to zero, then disable the old key. Without that overlap window, every rotation is an outage, so in practice the rotation never happens and the key lives for years. Plan for two-key validity on day one even if you only ever use one slot.

The coarse-grained trap. An API key proves identity but rarely carries fine-grained permissions. If one key can do everything an integration can do, a single leak is a full compromise. Scope keys to the smallest set of actions and resources the caller actually needs, and issue separate keys per environment and per integration so a leak is contained.

Sessions versus tokens

Before the named schemes, one split runs underneath all of them: where does the proof of identity actually live? There are two answers, and they trade off the same way every time.

A stateful session keeps the truth on the server. The client holds an opaque session id, usually in a cookie, that means nothing on its own. On every request the server looks the id up in a store — a database, Redis, a memory cache — and reads who the user is and what they may do from there. Logging someone out is a single delete. Changing their permissions takes effect on the next request. The cost is that lookup: every authenticated request touches shared state, and that store has to be fast, available, and shared across all your servers.

A stateless token puts the truth in the client's hands, protected by a signature. A signed JWT carries the user id, the expiry, the scopes, all inside the token. The server verifies the signature and trusts the contents without any lookup, which means any server holding the public key can validate the token with no shared state at all. That is what makes tokens scale across many services. The cost is the mirror image of the session's strength: because there is no server-side record, you cannot easily revoke a token before it expires. The token is valid until its exp, full stop, unless you reintroduce a lookup.

PropertyStateful sessionStateless token
Where identity livesServer store, client holds an opaque idInside the signed token the client holds
Per-request costA lookup in shared stateA signature check, no lookup
RevocationInstant — delete the recordHard — valid until it expires
Horizontal scaleNeeds a shared, fast storeAny server with the key can verify
Permission changesEffective next requestEffective at next token issue

Plenty of real systems run a hybrid: a short-lived stateless access token for the common path, so most requests need no lookup, paired with a stateful refresh token and a small revocation list checked only when something must be killed early. That keeps the fast path stateless and still gives you a kill switch. The rest of this page mostly describes the stateless side, since that is where the protocol detail concentrates, but the session option is often the right answer for a plain browser app that talks to one backend.

OAuth 2.0 with PKCE

OAuth 2.0 is the standard for "let user U authorize app A to access service S without sharing U's password with A". The user is redirected to the service, signs in there, grants the requested scopes, and the service hands the app a token.

PKCE (Proof Key for Code Exchange, RFC 7636) closes a hole in the authorisation-code flow where a malicious app on the same device could intercept the redirect. Originally designed for mobile apps; now mandated for single-page apps and recommended for everyone.

# 1. App generates a random "verifier" and its SHA-256 hash, the "challenge".
verifier  = random_url_safe(43)
challenge = base64_url(sha256(verifier))

# 2. App redirects user to authorize endpoint
GET /authorize?
  response_type=code&
  client_id=ABC&
  redirect_uri=...&
  scope=read+write&
  code_challenge=$challenge&
  code_challenge_method=S256

# 3. User signs in. Service redirects back with a code.
# 4. App exchanges code for token, proving it's the same app:
POST /token
  grant_type=authorization_code&
  code=$code&
  code_verifier=$verifier        # the original, unhashed verifier

Use OAuth when:

  • You're an integration that needs delegated access on behalf of a user.
  • You have a third-party developer ecosystem.
  • You need fine-grained, revocable scopes per integration.

Notable details: store refresh tokens server-side only, never in the browser; use short-lived access tokens (15-60 minutes) and rotate refresh tokens on each use; never use the resource-owner-password-credentials flow.

It helps to know which flow you actually want, because OAuth defines several and most of the confusion comes from picking the wrong one. The authorization code flow with PKCE above is the one for users: a human is present, they sign in at the service, and the app gets a token to act on their behalf. There is no human in service-to-service calls, so OAuth has a separate, simpler flow for that case.

If you want the deeper protocol walkthrough, the dedicated OAuth and OpenID Connect pages cover the redirects, the consent screen, and how OIDC layers an identity token on top of OAuth's access token. You can also step through a token's whole life — issue, use, refresh, expire — in the JWT lifecycle simulator.

Client credentials, for service-to-service

When one of your services calls another and no user is involved, the client credentials flow fits. The calling service authenticates as itself — with a client id and secret, or a client certificate — straight to the token endpoint, gets an access token, and uses it on the downstream API. No redirect, no consent screen, no browser, because there is no human to ask.

service Athe callertoken endpointauth serverresource APIservice B1. POST /tokengrant_type=client_credentials2. access_token (short-lived)3. GET /data   Authorization: Bearer …no user, no redirect — the service authenticates as itself
Client credentials: get a token from the auth server, then call the downstream API with it. Useful for cron jobs, workers, and back-channel integrations.

The win over a static API key is that the access token is short-lived and scoped, so a leaked token expires on its own and a leaked one cannot do more than its scopes allow. The long-lived secret stays put in service A and is only ever sent to the token endpoint, never to the resource API. For higher-trust setups you can replace the client secret with a client certificate, which folds this flow together with mTLS and removes the shared secret entirely.

Scopes, the unit of permission

A scope is a named slice of authority that travels with a token. A token issued with charges:read can read charges and nothing more; one with charges:read charges:write can do both. Scopes are how OAuth turns a single login into many narrow grants: the user, or the calling service, asks only for what it needs, and the token it gets back is limited to exactly that.

Two rules keep scopes useful. First, request the least scope that does the job. An integration that only reads should never be issued a write scope, because the blast radius of a leak is the set of scopes on the leaked token. Second, enforce scopes at the resource server, not just at the screen where the user granted them. The grant is a promise; the check on each request is what makes it real. A token that claims charges:write should be rejected by the read-only endpoint that was asked to honour charges:read, and accepted by the write endpoint — that decision is the authorization half of the page's first diagram, driven by the scope claim inside the token.

JWT bearer tokens

A JWT (JSON Web Token, RFC 7519) is a JSON payload signed (or encrypted) by the issuer. The receiver verifies the signature and trusts the contents. The format is base64-encoded header.payload.signature:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9   # header
.eyJzdWIiOiJ1c2VyXzAwMSIsImV4cCI6...    # payload
.SfXlFRFcsmH0Y3uZ9_PA7OZk...            # signature

# decoded payload
{
  "iss": "https://auth.example.com/",
  "sub": "user_001",
  "aud": "billing-api",
  "exp": 1714823400,
  "iat": 1714820400,
  "scope": "charges:read charges:write"
}
headeralg, typ.payloadclaims: sub, exp, scope….signatureproves the first twoeyJhbGci…eyJzdWIi…SfXlFRFc…sign( base64(header) + "." + base64(payload), key )change either part and the signature no longer verifiesthe payload is readable by anyone — signed, not secret
A JWT is three base64 segments joined by dots. The signature is computed over the header and payload, so any edit to either breaks verification. Base64 is encoding, not encryption — never put secrets in the payload.

The signature is the whole point. The issuer signs the header and payload with a key; the receiver verifies that signature with the matching key. With a symmetric algorithm (HS256) the same secret signs and verifies, which only works when issuer and verifier trust each other with one key. With an asymmetric algorithm (RS256, ES256) the issuer signs with a private key and anyone can verify with the public key, which is what lets many independent services check tokens they did not issue. That public key is usually published at a well-known JWKS URL so verifiers can fetch it and rotate along with the issuer.

A handful of claims do the load-bearing work, and a careful verifier checks all of them, not just the signature. exp is the expiry and iat the issue time, so the token is only good for a window. iss names the issuer and must match the one you trust. aud names the audience — the API the token is for — and must match this API, or a token minted for one service can be replayed against another. sub is the subject, the user or service the token is about. Skipping the aud check is a common and quiet mistake, because the token verifies fine and is simply being accepted by the wrong service.

JWTs work well as the access tokens at the end of an OAuth flow. The receiver verifies signature and expiration without a database lookup, which scales nicely.

Two long-standing problems are worth knowing:

  • The "alg=none" hole. The JWT spec allows an unsigned variant. A library that trusts the alg header without enforcing a configured algorithm will accept forged tokens. Hard-code the expected algorithm at the verifier; reject anything else.
  • Revocation is awkward. Once issued, a JWT is valid until it expires. If you need to log a user out immediately, you need a revocation list (essentially a database lookup), which defeats the "no DB lookup" benefit. Short expiry (5-15 minutes) plus refresh tokens is the practical compromise.

Opaque tokens and introspection

The other shape of access token is the opposite of a JWT: a random opaque string that carries no readable contents. The token means nothing on its own — the truth lives at the issuer. When a resource server receives one, it calls the issuer's introspection endpoint (RFC 7662), hands over the token, and gets back whether it is active and, if so, its subject, scopes, and expiry.

This brings back the trade-off from the sessions discussion. A JWT is self-contained and needs no call, but cannot be revoked early. An opaque token needs a call on every check, but the issuer is the single source of truth, so revoking it is instant — drop the record and the next introspection says inactive. You pay a network round trip for a kill switch that actually works. Many setups soften the cost by caching introspection results for a few seconds, which trades a small revocation delay for far fewer calls.

A reasonable rule: reach for JWTs when verifiers are many and independent and you can live with a short window before a token dies on its own; reach for opaque tokens plus introspection when instant revocation matters more than avoiding the lookup, or when you do not want token contents readable by whoever holds them. The two are not mutually exclusive — an auth server can issue both and let each resource server pick.

Mutual TLS

Both ends of the TLS connection present certificates. The server verifies the client's cert chain back to a trusted CA, looks up the identity in its Subject, and treats the connection as authenticated for that identity.

Use mTLS when:

  • Your callers are services you operate (service mesh, internal RPC).
  • You need authentication that survives stolen bearer tokens — the private key never leaves its host.
  • Your environment can run a private CA and rotate certificates frequently. Tools like SPIRE or AWS Private CA make this less painful than it used to be.

It scales poorly for public APIs because every caller has to enroll a certificate, but it's the gold standard for internal east-west traffic.

What makes mTLS stronger than a bearer token is that identity is proof of possession, not possession of a string. A bearer token works for anyone who holds it; a client certificate works only for whoever holds the matching private key, and that key never leaves the host or travels on the wire. Stealing a token off a log is enough to impersonate the caller; stealing an mTLS identity means stealing a private key, which is a much higher bar. The trade is operational weight: someone has to run a certificate authority, issue certs to every service, and rotate them before they expire. Short-lived certificates that a sidecar renews automatically — the model SPIFFE and SPIRE push — turn that rotation from a quarterly fire drill into something that happens every few hours without anyone noticing.

HMAC-signed requests (SigV4-style)

AWS's SigV4 is the canonical example. The client computes an HMAC over a canonical-form representation of the entire request (method, URL, sorted headers, body hash, timestamp). The server re-computes from the request it actually received and compares.

Authorization: AWS4-HMAC-SHA256
  Credential=AKIA.../20260504/us-east-1/s3/aws4_request,
  SignedHeaders=host;x-amz-content-sha256;x-amz-date,
  Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024

It's heavier than a bearer token but resistant to replay (the timestamp is part of the signed canonical request) and resistant to body tampering (the body hash is part of the signed input). Useful for payment APIs, anywhere you'd send a webhook and want to authenticate the call without giving the receiver a long-lived secret.

The replay resistance is worth pulling apart, because it is the reason this scheme exists. A bearer token sent over a captured request can be resent as-is; nothing in the request ties it to a moment in time. An HMAC signature covers the timestamp and the full request body, so a captured request only stays valid for the few minutes the server's clock-skew window allows, and any change to the body — a different amount, a swapped account number — breaks the signature outright. The server keeps a short window of recently seen signatures to reject exact replays inside that window, and rejects anything whose timestamp is too far off. The secret used to compute the HMAC is never sent; only the signature derived from it travels, so a captured request does not leak the key the way a bearer token leaks itself.

The cost is that both sides must agree on the exact canonical form — the same header ordering, the same encoding, the same body hashing — or signatures will not match, and getting that canonicalization wrong is the usual source of "signature mismatch" debugging. That fiddliness is why bearer tokens win for ordinary calls and HMAC is reserved for the cases where replay and tampering resistance earn their keep: payments, webhooks, and anywhere you hand a secret to a party you only half trust.

Putting it together

CallerDefault choiceWhy
Backend service you controlAPI key or mTLSFew callers, high trust, easy to rotate.
End users (web, mobile)OAuth 2.0 + PKCE → short-lived JWTDelegated access, scoped, revocable.
Third-party integrationsOAuth 2.0 + PKCEPer-integration consent and revocation.
Service mesh, internal east-westmTLSNo bearer tokens to leak; identity tied to host.
Webhook receiversHMAC over body + timestampAuthenticates without sharing a long-lived token.

Common mistakes

  • Putting tokens in URLs. They end up in server logs, browser history, and analytics pixels. Always use the Authorization header.
  • Storing JWT-shaped session tokens client-side. A JWT in localStorage is readable by every script on the page. Use HTTP-only, Secure, SameSite cookies for browser sessions.
  • Trusting the JWT alg header. Validate against a server-configured algorithm, not the one the token claims to use.
  • No clock skew tolerance. Token exp / iat comparisons should allow ±60s of clock drift between client and server.
  • Single-secret API keys with no rotation path. Eventually you'll need to rotate. Plan for it from day one with overlapping validity.
  • Long-lived tokens. A token that lives for a year is a year-long window for whoever steals it, and with stateless tokens you cannot pull it back. Keep access tokens to minutes, not days, and lean on refresh tokens for the long tail.
  • Skipping the audience check. A token minted for one service verifies its signature fine at another service that shares the issuer. Always check that aud names this API before trusting the token.
  • Logging the Authorization header. Default request logging often captures headers. Redact the credential, or it sits in plaintext in every log sink and trace it touches.

There is a pattern under all of these. Every mistake on the list either widens the blast radius of a leak or removes a way to recover from one. Short lifetimes, scoped credentials, audience checks, and a working rotation path are not separate rules; they are the same instinct applied at each layer — assume the credential will leak, and make sure that when it does, the damage is small and the cleanup is fast. The schemes higher up the page each pick a different point on the trade between how much that costs to operate and how much protection it buys.

If you want this set in the wider context of identity, sessions, and password handling, the authentication page in the security track covers the ground from the user's side rather than the API's.

Further reading

Found this useful?