API Authentication Patterns

API Authentication Patterns

Your company exposes a public REST API. A mobile app, a partner’s backend server, and an internal billing microservice all need to call it — but each has fundamentally different trust characteristics. The mobile app is on a user’s device (untrusted, can be reverse-engineered). The partner’s server is in their data center (semi-trusted, you control access but not their infrastructure). The internal microservice is inside your VPC (trusted, you control both sides). Using the same authentication scheme for all three is either too weak for one or too complex for another. Each integration pattern has a purpose-built authentication mechanism, and choosing the wrong one creates security holes or unnecessary friction.

The Landscape: What Problem Does Each Scheme Solve?

flowchart TB
    subgraph "Who is calling your API?"
        ExtUser([External User
via browser/mobile]) ExtSvc([External Partner
server-to-server]) IntSvc([Internal Microservice
within your infra]) end ExtUser -->|"OAuth 2.0 +
JWT Bearer"| API[Your API] ExtSvc -->|"API Key +
HMAC Signing"| API IntSvc -->|"mTLS or
JWT with short expiry"| API style ExtUser fill:#fbb style ExtSvc fill:#fbf style IntSvc fill:#bfb
CallerTrust levelAuth schemeWhy this one
User via appLow — device is untrustedOAuth 2.0 + JWT BearerDelegated auth, scoped permissions, revocable
External partnerMedium — you control their access, not their codeAPI key + HMAC signingSimple identity + tamper-proof requests
Internal serviceHigh — you control both sidesmTLS or JWTStrong mutual identity, zero user involvement

API Keys

The Problem They Solve

You need a simple way to identify which application is calling your API, enforce per-client rate limits, and revoke access if a client misbehaves. You don’t need to know which user is making the request — just which application.

How They Work

An API key is an opaque random string (typically 32–64 characters) issued to each client. The client includes it in every request, usually in a header.

sequenceDiagram
    participant Dev as Developer
    participant Portal as API Portal
    participant Client as Client App
    participant API as Your API

    Dev->>Portal: Register application
    Portal-->>Dev: API key: example_key_

    Client->>API: GET /api/weather?city=london
X-API-Key: example_key_ Note over API: 1. Hash the received key
2. Look up hash in DB
3. Check: active? rate limit ok?
4. Identify the client application API-->>Client: 200 OK {temperature: 18, ...}

Why Hash the Key?

API keys are credentials — they grant access to your API. If your database is breached and keys are stored in plaintext, every client is instantly compromised. Hashing with SHA-256 means the attacker gets hashes they can’t reverse.

Stored in DB:       key_hash = SHA256("example_key_...")
Attacker steals DB: sees "a1b2c3d4e5..." (useless without the original key)
Client sends key:   server computes SHA256(received_key), compares with stored hash

Treat API keys exactly like passwords: hash before storing, transmit only over HTTPS, allow rotation, support revocation.

Limitations of API Keys Alone

ProblemWhy API keys can’t solve it
No tamper protectionAn attacker who intercepts the key can replay any request, modify parameters, or forge new requests
No request integrityThe server can’t verify that the request body wasn’t modified in transit (beyond TLS)
No user identityAPI keys identify the application, not the user — can’t do per-user authorization
Shared secret riskThe key is sent with every request — if any request is logged with headers, the key is exposed

For public APIs with simple needs (weather data, maps), API keys are sufficient. For APIs handling sensitive data or money, you need something stronger.

HMAC Signing

The Problem It Solves

An API key proves who the caller is, but it doesn’t prove the request wasn’t tampered with in transit. Even over HTTPS, there are scenarios where request integrity matters beyond transport-level encryption:

  • Logging systems that capture request headers (including the API key) — anyone with log access can forge requests
  • Replay attacks — an attacker records a legitimate request and replays it later
  • Proxy manipulation — a misconfigured intermediary proxy could modify request parameters

HMAC signing proves three things: (1) the request came from someone who holds the shared secret, (2) the request body was not modified, and (3) the request is fresh (not a replay).

How It Works

The client constructs a canonical string from the request components, computes an HMAC-SHA256 signature using a shared secret, and sends the signature with the request. The server reconstructs the same canonical string and verifies the signature.

sequenceDiagram
    participant Client as Client
    participant API as API Server

    Note over Client: Build canonical string:
method + path + timestamp + body_hash Note over Client: signature = HMAC-SHA256(canonical, secret_key) Client->>API: POST /api/orders
X-API-Key: client_abc
X-Timestamp: 1713800000
X-Signature: a7f3b2e1...

{"item": "widget", "qty": 5} Note over API: 1. Look up secret for client_abc
2. Check timestamp within ±5 min window
3. Rebuild canonical string from request
4. Compute HMAC-SHA256(canonical, secret)
5. Compare signatures (constant-time) API-->>Client: 200 OK {order_id: "ord_123"}

Client builds canonical string

A deterministic representation of the request: METHOD\nPATH\nTIMESTAMP\nSHA256(body). Sorting keys in the body ensures the same JSON always produces the same hash.

Client computes signature

HMAC-SHA256(canonical_string, secret_key) — the secret key is never sent over the wire. Only the signature is transmitted.

Server verifies

The server looks up the client’s secret by API key, rebuilds the same canonical string from the received request, computes the expected signature, and does a constant-time comparison (prevents timing attacks).

Replay Attack Prevention

Without timestamp:
  Attacker captures: POST /transfer {amount: 1000, to: "attacker"} + valid signature
  Attacker replays the exact same request 1 hour later → succeeds

With timestamp (±5 min window):
  t=0:    Client sends request with timestamp=1713800000, valid signature
  t=301s: Attacker replays with timestamp=1713800000
          Server: |now - 1713800000| = 301 > 300 → REJECT
  
  Attacker tries changing timestamp to current time:
          But signature was computed with the original timestamp
          Changing the timestamp invalidates the signature → REJECT

The timestamp is included in the signed canonical string, so the attacker can’t modify it without invalidating the signature, and can’t replay the original request after the time window expires.

Who Uses HMAC Signing?

ServiceHow they use it
AWS (Signature V4)Signs method + path + headers + body hash + timestamp with secret access key
Stripe (webhook signatures)Signs webhook payload with endpoint secret; receiver verifies to prevent spoofed webhooks
TwilioSigns request body for webhook callbacks
GitHub (webhook secrets)HMAC-SHA256 of webhook payload, verified by the receiver

mTLS (Mutual TLS)

The Problem It Solves

Standard TLS (HTTPS) verifies that the server is who it claims to be — the client checks the server’s certificate. But the server doesn’t verify the client’s identity at the TLS level. Any client that can reach the endpoint can send requests.

For internal microservices, you need both sides to prove their identity: Service A must prove it’s Service A before Service B accepts its request. This is the authentication part of zero-trust networking.

sequenceDiagram
    participant A as Service A
(client) participant B as Service B
(server) participant CA as Certificate Authority
(internal CA) Note over CA: Issues certificates to both services A->>B: TLS ClientHello B->>A: TLS ServerHello + Server Certificate Note over A: A verifies B's certificate
against the CA (standard TLS) B->>A: CertificateRequest A->>B: Client Certificate Note over B: B verifies A's certificate
against the CA (the "mutual" part) Note over A,B: TLS handshake complete
Both identities verified
Encrypted channel established A->>B: GET /api/users/123
(over encrypted channel) B-->>A: {user: ...}

Why mTLS for Internal Services?

PropertyWhy it matters
Identity at the transport layerThe service identity is proven before any application code runs — the TLS handshake itself is the authentication
No shared secrets in application codeUnlike API keys or JWTs, there’s no token to leak in logs, environment variables, or error messages
Certificate rotation is automatedTools like cert-manager (Kubernetes), Vault, or SPIFFE/SPIRE auto-rotate certificates without deploys
Works with any protocolgRPC, HTTP, TCP — anything that runs over TLS. Protocol-agnostic
Service mesh integrationIstio/Envoy sidecar proxies handle mTLS transparently — application code is unaware

Certificate Management at Scale

flowchart LR
    subgraph "Certificate Lifecycle"
        Issue[CA Issues Cert
validity: 24 hours] --> Deploy[Deploy to Service
auto-injected by infra] Deploy --> Use[Service Uses Cert
for mTLS connections] Use --> Rotate[Auto-Rotate
before expiry] Rotate --> Issue end subgraph "Tools" V[HashiCorp Vault
PKI secrets engine] CM[cert-manager
Kubernetes] SP[SPIFFE/SPIRE
workload identity] end V & CM & SP -.-> Issue

Short-lived certificates (hours, not years) are the modern best practice. If a certificate is compromised, it expires before the attacker can exploit it. This eliminates the need for a Certificate Revocation List (CRL) or OCSP — the certificate simply stops working.

When mTLS Is Overkill

mTLS adds complexity: certificate authority setup, cert distribution, rotation automation, and debugging TLS handshake failures. For simpler internal service communication, JWT with short expiry is often sufficient and easier to operate.

JWT Bearer Tokens

The Problem They Solve

After a user authenticates (via OAuth 2.0), the application needs to include proof of authentication in every subsequent API call. The server must validate this proof without calling the auth server on every request — at 100K requests/second, that would be a bottleneck.

JWTs solve this because they’re self-contained: the token itself carries the claims (user ID, scopes, expiry) and a cryptographic signature. Any server with the public key can validate the token locally.

(The mechanics of JWT structure, signing, and validation are covered in detail in the JWT post. This section focuses on the Bearer token usage pattern.)

sequenceDiagram
    participant C as Client
    participant API as API Server

    C->>API: GET /api/orders
Authorization: Bearer eyJhbGciOiJS... Note over API: Extract token from Authorization header
Validate signature (local, ~50µs)
Check exp, aud, iss claims
Extract sub → user_id, scope → permissions alt Token valid API-->>C: 200 OK {orders: [...]} else Token expired API-->>C: 401 {error: "token_expired"} else Token invalid API-->>C: 401 {error: "invalid_token"} end

Bearer Token Security Rules

The word “Bearer” means whoever bears (holds) this token is granted access. There’s no proof of possession — if someone steals the token, they can use it.

Rules:
  1. ALWAYS transmit over HTTPS (never HTTP)
  2. NEVER log tokens (mask in logs: "Bearer eyJ...REDACTED")
  3. NEVER embed in URLs (?token=...) — URLs are logged everywhere
  4. Store in memory or httpOnly secure cookies — never localStorage
  5. Short expiry (15 min) to limit stolen token window
⚠️

Never put Bearer tokens in URL query parameters. URLs appear in server access logs, browser history, referer headers, and proxy logs. A token in a URL ?access_token=eyJ... is visible to every intermediary that handles the request. Always use the Authorization: Bearer header.

Choosing the Right Scheme: Decision Framework

flowchart TB
    Start([API Authentication
Decision]) --> Q1{Who is the caller?} Q1 -->|"End user
via browser/mobile"| OAuth[OAuth 2.0 + PKCE
→ JWT Bearer Token] Q1 -->|"External partner
server-to-server"| Q2{Need request
integrity?} Q2 -->|"Yes — financial,
webhook"| HMAC[API Key +
HMAC Signing] Q2 -->|"No — simple
read API"| AK[API Key
in header] Q1 -->|"Internal
microservice"| Q3{Infrastructure
maturity?} Q3 -->|"Service mesh
or Vault"| MTLS[mTLS
certificate-based] Q3 -->|"Simple setup"| JWTINT[JWT with
short expiry] Q1 -->|"Third-party
OAuth integration"| CC[OAuth 2.0
Client Credentials] style OAuth fill:#bfb style HMAC fill:#bfb style AK fill:#ffb style MTLS fill:#bfb style JWTINT fill:#bfb style CC fill:#bfb

Head-to-Head Comparison

PropertyAPI KeyHMAC SigningmTLSJWT Bearer
Identity proofApplication IDApplication ID + request integrityService identity (certificate)User + application identity
Request integrityNoYes (signed body + path + timestamp)Yes (TLS encryption)No (token only, not request body)
Replay protectionNoYes (timestamp window)Yes (TLS session)Partial (expiry, but within window)
Setup complexityMinimalModerate (signing logic on client)High (CA, cert distribution, rotation)Moderate (auth server, JWKS)
RevocationDelete key from DBDelete key from DBRevoke certificate (or wait for expiry)Short expiry + refresh token revocation
Credential exposure riskKey in every request headerKey never sent — only signatureCertificate on disk, auto-rotatedToken in every request header
Validation costDB lookup per requestDB lookup + HMAC computationTLS handshake (then session reuse)Local crypto only (~50µs)

Real-World Patterns

ScenarioRecommended schemeExample
Public API for developersAPI key (identity) + OAuth 2.0 (user data access)Google Maps API key + OAuth for user data
Webhook receiverHMAC signature verificationStripe webhook Stripe-Signature header
Internal microservicesmTLS (service mesh) or JWT service tokensIstio sidecar proxies handle mTLS transparently
Mobile app accessing user dataOAuth 2.0 Authorization Code + PKCE → JWT BearerInstagram API, Spotify API
Partner server integrationAPI key + HMAC signingAWS S3 API (Signature V4)
CLI tool or CI/CD pipelineOAuth 2.0 Device Flow or short-lived API tokengh auth login, aws configure
Service-to-service (no user)OAuth 2.0 Client Credentials → JWTBackend billing service calling user API

Layering Schemes Together

In practice, production APIs often combine multiple schemes:

flowchart LR
    subgraph "External Request"
        R1[API Key
identifies the app] --> R2[OAuth JWT
identifies the user] R2 --> R3[Rate limit
per API key] end subgraph "Webhook Callback" W1[HMAC Signature
proves authenticity] --> W2[Timestamp
prevents replay] end subgraph "Internal Service Call" I1[mTLS
proves service identity] --> I2[JWT
carries user context] end

Example: Stripe’s model

  • Public API calls: API key (example_key_...) in Authorization: Bearer header identifies the merchant account + authenticates
  • Webhook deliveries to your server: HMAC-SHA256 signature in Stripe-Signature header, verified with your webhook secret
  • User-initiated actions: OAuth 2.0 Connect for platform integrations, JWT for session management
ℹ️

Interview tip: When API authentication comes up, say: “I’d choose the scheme based on the caller type. For user-facing requests from a mobile app, OAuth 2.0 with PKCE gives me delegated auth and scoped JWT Bearer tokens — validated locally at the API gateway with zero DB calls. For external partner integrations that modify data, I’d use API keys for identity plus HMAC request signing — the partner signs the method, path, timestamp, and body hash with their secret key, which prevents tampering and replay attacks. For internal service-to-service calls, mTLS with short-lived certificates from an internal CA gives me mutual identity verification at the transport layer — the service mesh handles it transparently. API keys are stored hashed (like passwords), JWTs are validated by signature check against JWKS, and HMAC uses constant-time comparison to prevent timing attacks.” This shows you match the scheme to the trust model and understand the security details.