~/learn / apis / api-authentication

How APIs Authenticate Callers

API keys, bearer tokens, HMAC-signed requests, DPoP, and mutual TLS compared, with exactly how request signing and token binding work, where each fits, and how each fails.

intermediateupdated

A browser user logs in with a password and gets a session. API callers are often programs, so they prove identity other ways. Each method trades simplicity against security, and the differences come down to one question: what happens when the credential leaks.

The options

Method Looks like Best for Main risk
API key X-API-Key: sk_live_... Server-to-server, simple integrations Long-lived, leaks in code and logs
Bearer token Authorization: Bearer eyJ... User-delegated access via OAuth Anyone holding it can use it
Signed request Authorization: HMAC ... plus a timestamp High-value APIs: payments, cloud Implementation complexity
Mutual TLS A client certificate in the handshake Internal services, banking Certificate management

API keys

An API key is a long random string identifying the caller. Simple, and everywhere. Trouble comes because keys are long-lived and get copied where they should not be: frontend JavaScript, mobile apps, public repositories, CI logs. A key embedded in a mobile app is not a secret; it can always be extracted.

Good practice:

  • Keep keys server-side only, never in client code.
  • Scope each key to minimum permissions and, where possible, to specific source IPs.
  • Support multiple active keys so rotation causes no downtime.
  • Give keys a recognizable prefix (sk_live_, sk_test_). Secret scanners on GitHub detect prefixed keys and can auto-alert the provider, which is a real safety net when one is pushed by accident.
  • Give a key enough entropy (128 bits or more from a CSPRNG) that it cannot be guessed, and store only a hash of it server-side so a database leak does not reveal usable keys.

Bearer tokens

“Bearer” means exactly that: whoever bears the token may use it, no further proof required. OAuth access tokens and JWTs are usually sent this way. Keep them short-lived and send them only over TLS, because a bearer token captured anywhere (a log, a proxy, a Referer) is immediately usable by anyone.

DPoP (RFC 9449) fixes the bearer weakness by binding the token to a key the client holds. The client sends a short signed proof (a DPoP header) alongside the token, demonstrating possession of the private key the token was issued against. A stolen token without the matching private key is then useless, which turns a “whoever holds it” credential into a “whoever can prove key possession” one. This is the same idea as request signing, applied to tokens.

Signed requests, step by step

Instead of sending the secret, the client uses it to sign each request. AWS Signature Version 4 is the canonical example, and the mechanism is worth seeing:

  1. Build a canonical request: the method, path, sorted query, selected headers, and a hash of the body, assembled in a fixed, unambiguous format.
  2. Compute signature = HMAC(secret_derived_key, canonical_request + timestamp).
  3. Send the signature and timestamp in headers; the secret itself never travels.
  4. The server rebuilds the canonical request from what it received, recomputes the HMAC with its copy of the secret, and compares in constant time.

This gives three properties bearer tokens lack: the secret never crosses the network, any tampering with the signed parts breaks the signature, and the timestamp bounds replay to a short window (the server rejects requests outside a few minutes of skew, often with a nonce cache to block exact replays inside it). The cost is implementation complexity, which is itself a source of bugs if the canonicalization is done loosely.

Webhook providers use the same idea in reverse: they sign the payload they send you, and you must verify the signature (and the timestamp, to stop replay) before trusting it. Unverified webhooks are a frequent and high-impact finding, because an attacker who can POST to your webhook URL can forge events like “payment succeeded.”

Mutual TLS

In normal TLS only the server proves its identity. In mutual TLS the client also presents a certificate during the handshake, and the server validates it against a trusted CA. It is strong because the client’s private key never leaves the client and authentication happens at the transport layer before any application data flows. It is common between microservices in a service mesh and in open banking, where the operational cost of managing and rotating client certificates is acceptable for the assurance it buys.

Rate limiting and abuse controls

Authentication says who is calling; rate limiting says how much they may call, and the two are independent controls. Look for limits:

  • Per credential and per source IP.
  • On expensive or sensitive operations: login, password reset, OTP verification, search, export.
  • That return 429 Too Many Requests with a Retry-After header.

Missing limits on OTP and password-reset endpoints are among the most impactful API findings, because they make brute force practical: a six-digit code is only a million guesses, trivial without a limit, and (as covered in GraphQL) alias batching can defeat a limiter that counts requests rather than operations.

Field notes: where credentials leak and how they break

Most API-auth findings are not clever attacks, they are harvested secrets. Where to look:

  • JavaScript bundles and source maps ship API keys constantly: grep -rEi '(api[_-]?key|secret|token|bearer)' main.js. Source maps (.map) reconstruct the original source with comments.
  • Git history and public repos. trufflehog and gitleaks scan a repo’s full history for high-entropy strings and known key formats (AKIA... for AWS, ghp_ for GitHub, sk_live_ for Stripe). A key deleted in the latest commit still lives in history.
  • The token itself. A JWT bearer carries the entire JWT attack surface: alg:none, key confusion, weak-secret cracking with hashcat mode 16500.

Field notes: breaking the mechanism

  • Webhook HMAC bypass. If the server verifies a signature but you can send the request without the signature header, test whether verification is skipped when the header is absent (very common). Also test for timing-unsafe == comparison and for signing over the parsed body instead of the raw bytes (re-order or re-encode JSON to break the link).
  • Signature replay. An AWS SigV4-style or HMAC-signed request with no nonce or timestamp binding can be captured and replayed. Check whether the same signed request works twice.
  • API key as identity, not authorization. A valid key authenticates an app, but many APIs then trust a user_id or account_id parameter in the request, which is IDOR behind a valid key.
  • Rate-limit evasion. Rotate X-Forwarded-For, add a trailing dot or case change to the path, or use GraphQL alias batching to multiply attempts per request. A per-IP limit that trusts a spoofable header is no limit.

Only test credentials and endpoints you are authorized to assess, and treat any key you find as live and sensitive.

Takeaway

No method is perfect, and the right lens is leak-resistance. Prefer short-lived, scoped, server-side credentials; bind tokens to a key with DPoP or mTLS where the stakes justify it; sign high-value requests so the secret never travels; verify every webhook; and rate limit anything worth guessing.