Skip to content

API Design Concepts

This page collects principles and checklists for designing HTTP (Hypertext Transfer Protocol) APIs (Application Programming Interfaces) your clients can rely on. The Fundamentals section below is written to stand alone; later sections stay as topic maps for deeper pages (caching, rate limits, and so on).

Abbreviations: On first use in prose, terms appear as Full form (ABBR). A short reference: JSON (JavaScript Object Notation), JWT (JSON Web Token), OAuth (Open Authorization), OIDC (OpenID Connect), PKCE (Proof Key for Code Exchange), JWKS (JSON Web Key Set), IdP (Identity Provider), BFF (Backend for Frontend), TLS / mTLS (Transport Layer Security / mutual TLS), RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control), ACL (Access Control List), CSRF (Cross-Site Request Forgery), XSS (Cross-Site Scripting), HMAC (Hash-based Message Authentication Code), SPA (Single-Page Application), REST (Representational State Transfer), SSO (Single Sign-On), SAML (Security Assertion Markup Language), CDN (Content Delivery Network), WAF (Web Application Firewall), CSP (Content Security Policy), PWA (Progressive Web App), RLS (Row-Level Security), gRPC (gRPC Remote Procedure Call), SaaS (Software as a Service), B2B (Business-to-Business), CI (Continuous Integration), DX (Developer Experience).

1. API (Application Programming Interface) contracts and schema design

Section titled “1. API (Application Programming Interface) contracts and schema design”

A contract is the shared truth between producer and consumer: URLs, methods, headers, request/response bodies, error shapes, and evolution rules. Prefer contract-first (OpenAPI (Open API Specification) or JSON (JavaScript Object Notation) Schema authored and reviewed before coding) for public and partner APIs; code-first can work for internal services if the generated spec is treated as a publishable artifact.

Principles

  • Schemas are contracts — document every field’s type, nullability, allowed enums, and defaults. “Obvious in the code” is not obvious to integrators.
  • Prefer additive changes — new optional fields and new endpoints are safer than renaming or repurposing fields.
  • Breaking vs non-breaking — treat narrowing types, removing fields, adding required fields without defaults, and changing URL shapes as breaking; version or dual-publish during migration.
  • Consistency — one style for naming (snake_case vs camelCase), dates (always RFC (Request for Comments) 3339 / ISO (International Organization for Standardization)-8601 in UTC (Coordinated Universal Time)), money (integer minor units or decimal + currency), and error objects across resources.

Practices

PracticeWhy
Publish OpenAPI 3 (or AsyncAPI (Asynchronous API Specification) for events)Machine-readable docs, mocks, contract tests, client codegen.
Use JSON Schema for payloadsReuse in validators, CI (Continuous Integration), and documentation.
Version default response shape carefullyUnknown fields in requests: ignore or reject—pick one and document.

Avoid

  • Undocumented “flags” and magic integers without an enum or x- extension in the spec.
  • Overloading one field for multiple meanings depending on another field without an explicit discriminator or union style in the schema.

Example: OpenAPI 3.0 document shape (YAML)

Section titled “Example: OpenAPI 3.0 document shape (YAML)”

YAML (YAML Ain’t Markup Language) example. OpenAPI bundles paths (operations), parameters, request bodies, responses, and reusable components/schemas. Tools (Swagger UI (User Interface), Redoc, codegen, Dredd, Spectral) all read the same file.

openapi: 3.0.3
info:
title: Example Orders API
version: 1.0.0
servers:
- url: https://api.example.com/v1
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrderInput"
responses:
"201":
description: Created
headers:
Location:
schema:
type: string
example: https://api.example.com/v1/orders/550e8400-e29b-41d4-a716-446655440000
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"422":
description: Business validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"404":
description: Not found for this caller
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
components:
schemas:
CreateOrderInput:
type: object
required: [currency, line_items]
properties:
currency:
type: string
description: ISO 4217 alphabetic code
pattern: "^[A-Z]{3}$"
example: USD
line_items:
type: array
minItems: 1
items:
$ref: "#/components/schemas/LineItem"
customer_note:
type: string
nullable: true
LineItem:
type: object
required: [sku_id, quantity]
properties:
sku_id:
type: string
quantity:
type: integer
minimum: 1
Order:
type: object
required: [id, status, created_at, currency, total_minor_units]
properties:
id:
type: string
format: uuid
status:
type: string
enum: [DRAFT, PAID, SHIPPED, CANCELLED]
created_at:
type: string
format: date-time
currency:
type: string
pattern: "^[A-Z]{3}$"
total_minor_units:
type: integer
format: int64
minimum: 0
line_items:
type: array
items:
$ref: "#/components/schemas/LineItem"
ApiError:
type: object
required: [code, message, trace_id]
properties:
code:
type: string
example: VALIDATION_FAILED
message:
type: string
trace_id:
type: string
format: uuid
details:
type: array
items:
type: object
properties:
field:
type: string
issue:
type: string

Reading the contract

  • $ref ties operations to canonical types in components/schemas so you do not duplicate the same object in every path.
  • required + nullable: true documents “key present, value may be null” vs “key omitted” (pick one convention per field and stick to it).
  • enum, pattern, format, minimum give integrators and validators the same rules your server enforces.

OpenAPI 3.1 uses JSON Schema closer to modern drafts; 3.0.x is still the most common in enterprise pipelines—same ideas apply.

Example: same payload as standalone JSON Schema

Section titled “Example: same payload as standalone JSON Schema”

When you validate in app code or CI without loading full OpenAPI, you often keep a JSON Schema file per resource or embed schema under components/schemas (OpenAPI 3.0’s schema objects are very close to JSON Schema).

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/order.json",
"title": "Order",
"type": "object",
"required": ["id", "status", "created_at", "currency", "total_minor_units"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "format": "uuid" },
"status": {
"type": "string",
"enum": ["DRAFT", "PAID", "SHIPPED", "CANCELLED"]
},
"created_at": { "type": "string", "format": "date-time" },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"total_minor_units": { "type": "integer", "minimum": 0 },
"line_items": {
"type": "array",
"items": {
"type": "object",
"required": ["sku_id", "quantity"],
"properties": {
"sku_id": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
}
}
}
}
}

additionalProperties: false is strict: unknown fields fail validation—good for inbound partner payloads when you want to reject surprises early (for responses, many public APIs allow unknown fields and rely on clients ignoring extras).

Change in OpenAPI / JSON SchemaUsually
Add optional property with a default or clearly optional semanticsNon-breaking for responses; safe for requests if server ignores unknowns.
Add required field on request body with no defaultBreaking for existing clients.
Narrow enum, tighten pattern, add maximum smaller than beforeBreaking if clients or data could violate the new rule.
Rename propertyBreaking unless you dual-publish old + new names for a deprecation window.

Authentication (authn) answers who is calling (identity). Authorization (authz) answers what they may do (scopes, roles, tenant, row-level checks). (Abbreviated as authn / authz below.) Keep the distinction visible in docs and in code (authenticate early, authorize at the resource or policy layer). TLS (Transport Layer Security) and mTLS (mutual TLS) are covered below; OAuth (Open Authorization) / OIDC (OpenID Connect) are the usual user-login path for APIs.

plantuml

TLS vs mTLS (transport vs application identity)

Section titled “TLS vs mTLS (transport vs application identity)”

TLS (Transport Layer Security; what you get with normal HTTPS (HTTP Secure)) encrypts traffic and lets the client verify the server using the server’s certificate (CA (Certificate Authority)-signed). The client stays anonymous at the TLS layer; you prove who the client is later with HTTP credentials (API key, Bearer token, cookie).

mTLS (mutual TLS) is still TLS, but the client also presents a certificate during the handshake. The server verifies the client cert (against a CA or trust store) before accepting the connection. Identity is bound to the certificate subject (e.g. CN=payments-service)—often used for service-to-service calls where there is no human and no OAuth login.

When to use which at the transport layer

  • TLS (HTTPS) only — Default for almost every public API, browser, and mobile app. Encrypts traffic and authenticates the server. You still choose an application credential (API key, Bearer, cookie) for who the client is.
  • mTLS — Use when both ends are servers you control (or contractual partners) and you want machine identity at connect time before HTTP runs—common in meshes and regulated B2B. Not for end users (they do not install client certs).
TLS (HTTPS)mTLS
Who proves identity to whomServer → client (server cert)Both ways (server cert + client cert)
Where API “user” is establishedHTTP layer (Bearer, API key, cookie)TLS handshake and optionally HTTP again
Typical clientsBrowsers, mobile apps, partnersMicroservices, mesh sidecars, regulated B2B
RevocationCert expiry + OCSP (Online Certificate Status Protocol) for server certClient cert rotation, CRL (Certificate Revocation List)/OCSP, short-lived certs

plantuml

The diagrams below show authentication only. Authorization (scopes, RBAC (Role-Based Access Control), row ownership) still runs in your API after identity is known.

MechanismPrimary callerHuman vs machine
API keyPartner server, cron, scriptMachine (secret on server only)
Bearer JWTMobile, SPA, services after login/token exchangeUser or machine (depends who the token represents)
OAuth / OIDCBrowser, mobile app, third-party appHuman (or delegated app acting for user); client credentials = machine
Session cookieBrowser on your domainHuman
mTLSMicroservices, mesh, fixed B2B partnerMachine
HMAC / SigV4Webhook sender, cloud SDK, your outbound webhooksMachine
Signed URLBrowser/email link clickHuman (one-off action via link)

When to use: Machine-to-machine and server-side callers that need a simple, long-lived credential—partner integrations, batch jobs, internal scripts, and developer sandboxes. The secret lives only on servers (env vars, secrets manager), never in a browser or mobile binary you do not control.

Static secret issued per integration. Server looks up the key in a store (or validates a prefixed key) and attaches a principal (partner id, environment, quotas).

plantuml

Scenarios — why API keys are preferred there

Section titled “Scenarios — why API keys are preferred there”

1. B2B partner REST API

  • Caller: Partner’s backend cron or ERP system.
  • Why API key: Onboarding is one secret per partner; easy to document (X-API-Key), rate-limit per key, revoke one partner without affecting others.
  • Why not OAuth user flow: No human is logging in on each API call—the integration is system identity, not “Alice clicked Allow.”

2. Developer portal / sandbox

  • Caller: Developer’s server or curl with a test key.
  • Why: Fast time-to-first-request; keys map to sandbox data and lower quotas.

3. Internal automation

  • Caller: CI pipeline, nightly report job, ops script.
  • Why: Simple rotation (issue sk_live_… / sk_test_…); lookup returns partner_id + scopes for authz.
SituationBetter choiceReason
Browser or mobile appOAuth / OIDCKey in the client leaks (devtools, decompile, Referer).
Webhook body must not be forgedHMACKey in header does not prove this exact body was sent by the vendor.
Per-user “log in as me”OAuth scopes on user tokenAPI key is usually one principal per integration, not per end user.
Instant revoke one sessionShort-lived JWT or opaque tokenLong-lived key revoke affects all calls until rotated.

When to use: Callers that already obtained a token from an issuer (your auth server or an IdP) and send it on every API request—typical for user-facing apps (mobile, SPA via BFF), microservices after token exchange, and machine clients using client-credentials JWTs. The API validates cryptographically (or via introspection for opaque tokens) without a password on each call.

JWT (JSON Web Token): client sends Authorization: Bearer <jwt>. API (or gateway) verifies signature, iss (issuer), aud (audience), exp (expiry), and reads claims (sub (subject) = user id, tenant_id, scopes).

plantuml

JWT vs opaque: JWT avoids a DB (database) hit per request but is harder to revoke instantly; opaque tokens need introspection/session lookup but revoke cleanly.

Scenarios — why Bearer JWT is preferred there

Section titled “Scenarios — why Bearer JWT is preferred there”

1. Mobile / SPA calling your resource API

  • Flow: User logs in via OAuth → app holds short-lived access JWTAuthorization: Bearer on /v1/orders.
  • Why JWT: API verifies with JWKS locally—no session DB round-trip per request; claims carry sub, tenant_id, scopes for authz.

2. API gateway → microservices

  • Flow: Gateway validates user JWT once, forwards identity (headers or internal JWT).
  • Why: Same token format end-to-end; services trust the same iss / aud.

3. Service account (client credentials)

  • Flow: Worker exchanges client id/secret for an access JWT with service scopes.
  • Why: Machine identity with expiry—better than a forever API key when you already run an IdP or internal token service.
SituationBetter choiceReason
Inbound vendor webhookHMACVendor does not issue you a user JWT; they sign the request.
One-click download link in emailSigned URLNo Authorization header in a plain link click.
Must revoke access in under one minute globallyOpaque token + session store, or token blocklistValid JWT works until exp unless you add revocation infrastructure.
First-party admin on one domain onlySession cookieSimpler browser UX; HttpOnly cookie hides session id.
OAuth 2.0 (Open Authorization) / OIDC (OpenID Connect) (authorization code + PKCE)
Section titled “OAuth 2.0 (Open Authorization) / OIDC (OpenID Connect) (authorization code + PKCE)”

When to use: Human users (or their delegated third-party app) who must log in, consent to scopes, and obtain short-lived access tokens—consumer mobile/SPA, “Sign in with Google,” and Connect my GitHub account flows. For machine-only traffic with no user, use client credentials (still OAuth family) or API key / mTLS instead of authorization code.

User logs in at an IdP (Identity Provider); your app never sees the password. Recommended: authorization code + PKCE (Proof Key for Code Exchange), backend/BFF holds refresh_token, your API accepts IdP access JWT and validates with JWKS (JSON Web Key Set). Do not mint your own JWT unless you have a strong reason.

End-to-end (request order)

plantuml

  1. App creates PKCE: random code_verifier; code_challenge = SHA-256 (Secure Hash Algorithm 256-bit) hash of verifier on /authorize only.
  2. User authenticates at IdP; redirect /callback?code=…&state=…code, not access token in the URL (avoid legacy ?token= implicit flow).
  3. Backend/BFF POST /token with code + code_verifieraccess_token (often JWT), refresh_token, expires_in.
  4. Store refresh_token on the server (session/Redis (Remote Dictionary Server)); browser gets HttpOnly session cookie or short-lived access token — not refresh in localStorage.
TokenSent to your /v1/* API?Who uses it
Authorization codeNoOne-time; exchanged at /token
Access tokenYes (Authorization: Bearer)Every API request until exp
Refresh tokenNoBackend/BFF → IdP only when refreshing
ID token (OIDC)Usually no on resource APIClient UI (User Interface); API uses access token

PKCE: stops someone who steals code from exchanging it without the verifier. Verifier only in POST /token body; challenge in /authorize URL. Generated in browser (sessionStorage) or server (session).

GET /v1/orders
Authorization: Bearer <IdP access_token>

Your API (middleware) for JWT access tokens — usually no IdP HTTP call per request:

  1. Parse JWT; read header kid, alg
  2. Load IdP JWKS (public keys) — cache /.well-known/jwks.json
  3. Verify signature
  4. Check exp (expired → 401), iss (your IdP), aud (your API), scope
  5. Use sub as user id → then your authorization (ownership, RBAC) → 403 if denied
WhenCalls IdP?
Login + refreshYes (/authorize, POST /token)
Each JWT API requestNo — local JWKS verify
Opaque access tokenYes — introspection (or cache briefly)

3. Refresh — new access token without re-login

Section titled “3. Refresh — new access token without re-login”

When exp passes (or 401), backend/BFF (not your orders API) calls:

POST /token with grant_type=refresh_token → new access_token.

Browser calls /auth/refresh with session cookie; never sends refresh_token to api.example.com. If refresh fails (invalid_grant) → redirect to login again.

BFF (Backend for Frontend) vs your API (two roles, one server is OK)

Section titled “BFF (Backend for Frontend) vs your API (two roles, one server is OK)”
BFF / auth backendYour API (resource server)
Runs onYour server (not in the browser)Your server
JobOAuth callback, refresh, cookiesBusiness routes /v1/orders
Talks to IdPYesNo (validates JWT via JWKS)
Holds refresh tokenYesNo

Monolith: same deployable can do both /auth/* and /v1/*.

TermMeaning
IdPIdentity Provider — logs user in; issues tokens (Auth0, Azure AD, Keycloak, …)
OIDCOpenID Connect — OAuth2 plus standard user profile (ID token)
PKCEProof Key for Code Exchange — protects authorization code exchange
JWKSJSON Web Key Set — IdP public keys URL; verify JWT signature
kidKey ID — which JWKS public key signed the JWT
issIssuer — who minted the token (IdP URL)
audAudience — token intended for your API
expExpiration time — token invalid after this instant
subSubject — stable user id in your handlers
stateRandom value — anti-CSRF (Cross-Site Request Forgery) on OAuth redirect

Scenarios — why OAuth / OIDC is preferred there

Section titled “Scenarios — why OAuth / OIDC is preferred there”

1. Consumer app (mobile or SPA)

  • Need: Real user identity, password handled by IdP, optional social login, refresh without re-entering password.
  • Why authorization code + PKCE: No long-lived secret in the browser; code is useless without verifier; refresh_token stays on BFF/server.
  • Why not API key: Cannot put a static secret in the app binary or JS bundle.

2. Third-party app accessing user data (“Connect account”)

  • Need: User sees consent screen; you issue token with limited scopes (read:orders not write:payments).
  • Why OAuth: Standard delegation model; user can revoke access at the IdP.
  • Why not session cookie: The third party is not on your domain—they need a Bearer token, not your site’s cookie.

3. Enterprise SSO

  • Need: Employees use corporate IdP (Azure AD, Okta); your app trusts OIDC tokens and sub.
  • Why OIDC: Standard ID token + userinfo for profile; same flow as consumer OIDC with stricter tenant policies.

4. Machine-to-machine (no user)

  • Grant: Client credentials → access token for orders:sync scope.
  • Why still “OAuth”: Central token issuance, expiry, audit—when you already operate an IdP. If you only need a simple partner script, an API key may be enough.
SituationBetter choiceReason
Stripe/GitHub pushing events to youHMAC webhookNo user in the loop; provider signs POST.
Single-domain admin appSession cookieSimpler; no token in JS if HttpOnly.
Partner server with no user UIAPI key or mTLSNo consent screen needed; static or cert identity.
Anonymous public readNone + rate limitsLogin adds friction with no benefit.
  • /callback?token= access token in URL — use ?code= + server exchange.
  • Minting your own JWT when IdP already issued an access JWT — extra keys, refresh, two token types; default pass IdP token to API.
  • Refresh token in browser localStorageXSS (Cross-Site Scripting) risk; keep on server.

See HTTP status codes for 401 vs 403.

When to use: Human users in a browser on your site (or same-site BFF) where the server owns login state—traditional web apps, internal admin panels, and server-rendered dashboards. The browser sends the cookie automatically; the server maps session id → user in Redis/DB. Not for third-party integrators or native mobile apps calling a cross-origin API without cookie support.

After password/OIDC login, server creates a session server-side and sets an HttpOnly cookie. Browser sends cookie automatically on same-site or configured domain; API loads session from Redis/DB.

plantuml

Scenarios — why session cookies are preferred there

Section titled “Scenarios — why session cookies are preferred there”

1. Server-rendered web app (same origin)

  • Flow: POST /loginSet-Cookie: session=…; HttpOnly; Secure; SameSite → later GET /api/orders sends cookie automatically.
  • Why: No token handling in JavaScript; XSS cannot read HttpOnly session id; familiar server-side session invalidation on logout.

2. Admin panel on admin.yourcompany.com

  • Why: Small team, one domain, CSRF tokens for POST/PUT/DELETE; RBAC in session after login.

3. BFF + SPA on same site

  • Flow: SPA calls same-origin /api/*; BFF validates session cookie and may call downstream services with a service token.
  • Why: Refresh and secrets stay server-side; SPA never stores refresh_token in localStorage.
SituationBetter choiceReason
Public API for partnersAPI key or OAuth BearerPartners cannot send your domain’s cookies from their servers.
Mobile app → api.example.comOAuth + Bearer JWTCross-origin cookie rules and third-party cookie limits break simple cookie auth.
Webhook from vendorHMACNo browser, no cookie jar.

When to use: Machine-to-machine between services (or fixed B2B partners) where both sides present X.509 certificates at connection time—Kubernetes mesh, internal east-west traffic, and high-trust bank/partner links. Identity is the cert subject/SAN (or SPIFFE id), not a user password or OAuth login.

Trust is established in the TLS handshake via client certificate. HTTP may still run, but many meshes map cert identity to a service account without a separate Bearer token.

plantuml

1. Service mesh (Istio, Linkerd)

  • Flow: Sidecar terminates mTLS; forwards to app with identity header (SPIFFE://…).
  • Why: Default encrypted + authenticated east-west traffic without every app implementing Bearer validation.

2. Internal microservice A → B

  • Why: Network policy + cert identity = “only payments-service may call ledger-service” before HTTP even runs.

3. Regulated B2B (fixed partner)

  • Why: Contractual client cert per partner org; rotation via PKI; stronger than shared API key alone for high-value channels.
SituationBetter choiceReason
Public mobile / browser usersOAuth / sessionUsers do not install client certificates.
Millions of SaaS customers calling your cloud APISigV4 / API keysPer-customer client certs at edge do not scale like IAM keys.
Quick partner onboardingAPI keyFaster than cert issuance and PKI lifecycle.
HMAC (Hash-based Message Authentication Code) / request signing (webhooks and vendor APIs)
Section titled “HMAC (Hash-based Message Authentication Code) / request signing (webhooks and vendor APIs)”

When to use: Machine-to-machine integrations where one server sends HTTP to another and you must prove (a) the sender knows a shared secret and (b) the exact request (especially POST body) was not tampered with—inbound webhooks, payment callbacks, cloud SigV4 APIs, and outbound webhooks you sign for customers. Not for human login; not for browsers holding the signing secret.

Sender and receiver share a secret (a symmetric key both sides already know). Sender hashes a canonical string (method, path, timestamp, body); receiver recomputes and compares. Proves the request was crafted by someone who knows the secret and (with timestamp) limits replay.

How they get the same secret (out of band — never in the webhook body)

The secret is not sent on each POST. It is agreed or issued once, before traffic starts, through a channel that is not the signed webhook:

HowTypical flow
Provider dashboardYou register a webhook URL (e.g. Stripe, GitHub). The provider generates a signing secret and shows it once; you copy it into your app config or secrets manager. They keep a copy; every delivery is signed with that key.
You issue to a partnerDuring B2B onboarding you generate a random secret (or key pair’s HMAC secret), send it over email + separate channel, contract, or secure portal — same idea as provisioning an API key. Partner stores it; your server stores the same value.
Cloud IAM / API keysAWS SigV4 uses an access key ID (public identifier) + secret access key created in IAM; both are known to the caller and implied by the signature algorithm.
RotationProvider lets you roll to a new secret; you run two secrets briefly, then retire the old one.
[One-time setup — not the webhook request]
Provider ──► "whsec_abc123…" ──► Your env / Vault / K8s Secret
Partner ◄── onboarding portal ──► Same value in their config
[Every delivery]
Provider: signature = HMAC-SHA256(secret, canonical_request)
Your API: recompute with secret from env → must match X-Signature

Security notes: Treat the secret like a password (env vars, not git). TLS still encrypts the wire; HMAC proves who sent the payload and that it was not tampered with. If the secret leaks, anyone can forge valid signatures until you rotate.

plantuml

What HMAC proves (and what TLS alone does not)

Section titled “What HMAC proves (and what TLS alone does not)”
LayerGuarantees
TLSChannel is encrypted; you are talking to some server with a valid cert.
HMAC / request signatureThis exact HTTP message (method, path, headers, body, often timestamp) was produced by a party that knows the shared secret — and was not modified in transit after signing.

TLS does not tell your webhook handler “Stripe sent this body.” Anyone who can reach your URL over HTTPS could POST fake payment.succeeded events. HMAC closes that gap: only someone with the signing secret can produce a signature your code accepts.

All of these are request signing: build a canonical string from parts of the request, run a keyed hash, send the result in a header (or query). The receiver rebuilds the same string and compares (usually with a constant-time compare).

Algorithm / schemeTypical canonical inputWho uses itWhy that shape
HMAC-SHA256timestamp + "." + raw_body, or vendor-specific header listStripe, GitHub, Slack, many SaaS webhooksFast, widely supported in every language; 256-bit security is enough; simple for “sign the POST body.”
HMAC-SHA512Same idea, longer digestSome security-heavy or legacy integrationsStronger hash; less common for webhooks because SHA-256 HMAC is already standard.
AWS Signature Version 4 (SigV4)Canonical request: method, path, sorted signed headers, SHA256 hash of body, region, service, datetime; then nested HMAC chain with derived signing keyS3, API Gateway, Lambda invoke URLs, most AWS APIsOne algorithm for all AWS services; binds signature to credential scope (which service/region), clock, and exact headers AWS requires; supports temporary credentials (STS).
Custom “sign these N headers”e.g. Date, Host, Content-Digest, pathOlder payment gateways, some telco/B2B APIsLegacy contracts; harder for clients but pins more than body alone.

Canonical string matters: If sender signs POST\n/webhooks\n1730000000\n{...} and you verify POST/webhooks/1730000000{...} (missing newline), validation fails even with the right secret. Libraries must follow the vendor’s doc byte-for-byte.

Replay protection: Webhooks often include X-Timestamp (or similar). Reject requests outside a skew window (e.g. ±5 minutes) so an attacker who captures one signed request cannot replay it forever. Combine with idempotency (event_id) so duplicate deliveries are safe.

1. Inbound webhooks (Stripe, GitHub, Shopify, …)

  • Direction: Their servers → your public HTTPS URL. There is no logged-in user in your session — it is machine → machine.
  • Why HMAC: You need to know the event really came from the vendor, not a random client on the internet. A static API key in a header would work for auth but is often sent in cleartext in logs; signatures bind to body + time so tampering breaks verification.
  • Why not OAuth: OAuth proves a user delegated access to a client. Webhooks are the provider pushing to you; the “client” is your endpoint, and trust is contract + shared secret, not a user consent screen.
  • Operational pattern: Return 2xx quickly, verify signature before heavy work, process async; use idempotency keys on event_id because vendors retry on timeouts.

2. Payment and billing providers

  • Why stricter signing: Money movement triggers chargebacks, fraud review, and compliance. Providers want integrity (amount, currency, customer id in body were not altered) and authenticity (only their infrastructure signed it).
  • Often paired with: IP allowlists (weak alone), event IDs, dashboard to rotate signing secrets after leaks, separate test vs live secrets.
  • Why not session cookies: Your checkout page’s user session does not exist on the server-to-server webhook call.

3. Cloud control-plane APIs (AWS SigV4 and similar)

  • Caller: Your CI job, Terraform, backend worker — holds access key + secret (or role via STS).
  • Why SigV4 instead of a single HMAC header: Requests vary (GET vs POST, many headers, regions). SigV4 standardizes what is signed across hundreds of services and supports short-lived credentials without issuing a new long-term secret per call.
  • Why not mTLS alone: AWS’s public API is used by millions of customers; per-customer client certs at AWS’s edge is not the model — per-account keys + signature scale operationally.

4. Outbound webhooks you send to customers

  • You are the sender; customer verifies your HMAC with a secret you gave them at onboarding.
  • Why: Same trust problem in reverse — they must not act on forged “invoice.paid” events. Symmetric secret is simple for B2B integrations where both sides are servers.

5. Partner / internal job callbacks

  • Cron or queue worker calls POST /internal/jobs/complete with HMAC.
  • Why preferred over IP-only: IPs change (cloud NAT); secret + signature survives if the route is accidentally exposed past the firewall.

When HMAC is a poor fit (use something else)

Section titled “When HMAC is a poor fit (use something else)”
SituationBetter choiceReason
Human in browser or mobile appOAuth / OIDC, session cookieSecret in the app leaks; users need identity and consent, not a shared integration key.
Per-user revoke “now”Short-lived JWT + refresh, or opaque token + session storeHMAC secret is long-lived; revoking means rotating integration secret for all traffic from that sender.
Public read-only JSON APIAPI key or no auth + rate limitsNo body integrity requirement; signing every GET adds little if there is no secret action.
High-trust fixed partner on private networkmTLSIdentity at TLS layer; no per-request canonical string — good when both sides run certs.
One-off file downloadSigned URLUser clicks link; no custom headers on each request.

Integration auth vs user login (why it is “not a substitute”)

Section titled “Integration auth vs user login (why it is “not a substitute”)”
HMAC / integration signingUser login (OAuth, session)
Who is authenticatedA system (Stripe, your worker, AWS principal)A person (or their delegated app)
CredentialLong-lived shared secret or cloud keyTokens tied to user id, scopes, consent
Typical question“Did our vendor send this webhook?”“May this user see order 42?”
AuthorizationUsually implicit (“this integration may post events”)RBAC, ownership, scopes on each API call

Use HMAC (or SigV4) for trust between two backends. Use OAuth/session for end-user identity and permission. You will often use both on one product: users log in via OIDC; Stripe webhooks hit /webhooks/stripe with HMAC.

MechanismProvesBest when
API key in headerCaller knows a static secretSimple partner GETs; easy to leak in logs if not careful
HMAC / SigV4Secret + exact request unchangedWebhooks, cloud APIs, tamper-sensitive POSTs
Bearer JWTToken issued by IdP; claims for user/tenantUser-facing and microservice APIs with expiry
mTLSClient cert at handshakeService mesh, fixed partners with cert lifecycle
Signed URLPossession of time-limited linkDownloads, email links, not general CRUD

When to use: One-off or time-boxed access where the client cannot send custom headers—email links, “Download your export,” S3 pre-signed URLs, CDN edge authorization. Anyone with the full URL can perform the allowed action until expiry; security is secrecy of the URL + short lifetime, not ongoing session or API key.

Server pre-signs a URL (query params: expiry, signature, sometimes method); receiver validates signature and clock. Authentication is possession of URL, not Authorization: Bearer.

Scenarios — why signed URLs are preferred there

Section titled “Scenarios — why signed URLs are preferred there”

1. Email “download your invoice PDF”

  • Why: User clicks link in mail client—no login header, no API key in the browser.
  • Why not Bearer JWT: JWT in URL leaks via Referer, browser history, and server logs.

2. S3 / object storage direct download

  • Why: Browser or app GETs CDN/S3 directly; signature covers method, path, expiry, headers—offloads bytes from your API.

3. Password reset / magic link

  • Why: Single-use or short TTL proves intent to access account flow without storing long-lived credentials in the link (often one-time token + expiry).

4. Upload to bucket (presigned PUT)

  • Why: Client uploads large file directly to storage; your API only mints the URL after authz check.
SituationBetter choiceReason
General REST CRUD APIBearer / API keyEvery call would need new URL; headers are standard.
Long-lived API integrationAPI key or OAuthURLs expire; partners need stable auth.
High-sensitivity action without leak riskSession + POST with CSRFURLs get copied, forwarded, logged.
ScenarioRecommended authnWhy
Public REST API + developer portalAPI key (partner) + OAuth (user-linked)Keys are simple for servers; OAuth for “connect my account” with consent
Mobile / SPA consumer appOIDC (auth code + PKCE) → Bearer JWTNo long-lived secrets in the client; standard login UX
Server cron / worker → internal APIClient credentials JWT or mTLSNo user; machine identity; short-lived tokens or certs
Microservices inside a clustermTLS (mesh) ± service JWTStrong default identity at the network layer
Browser-only admin on one domainSession cookie (+ CSRF (Cross-Site Request Forgery) protection)Simple; cookies not exposed to JS if HttpOnly
Legacy internal toolHTTP Basic over HTTPS onlyEasy but rotate passwords; prefer SSO (Single Sign-On)/OIDC for new work
Receive events from Stripe/GitHubHMAC signature on webhookVerify sender; respond 2xx quickly
Let user download export file onceSigned URLNo API key in the browser for a one-off GET
High-trust B2B (fixed partners)mTLS or IP allowlist + API keyContractual trust; certs bound to partner org
Third-party app reads user’s dataOAuth scopes on access tokenUser consent + least privilege per scope

Quick decision hints

  • Human user in a browser or app? → OAuth/OIDC or session cookie, not a static API key in the client.
  • Machine calling machine? → Client credentials, mTLS, or scoped API key stored in secrets manager.
  • Prove the HTTP request was not tampered with? → HMAC signing (webhooks), not just TLS.
  • Only need encryption + “is this really api.example.com”? → Normal TLS is enough; add Bearer/API key for who the client is.
MechanismHow the client proves identityTypical use
API keyStatic secret in header (X-API-Key, Authorization: ApiKey …)Partner integrations, internal jobs, dev sandboxes
HTTP BasicAuthorization: Basic base64(user:pass)Legacy internal tools only—always over HTTPS
Bearer token (JWT or opaque)Authorization: Bearer <token>Mobile, SPA (via BFF), microservices
OAuth 2.0 / OIDCToken from token endpoint, then BearerUser login, third-party apps, client credentials for machines
Session cookieCookie: session=… after loginTraditional web apps, same-site or BFF
mTLSClient X.509 certificate in TLS handshakeService mesh, regulated B2B
HMAC / request signingSignature over canonical requestWebhooks, AWS-style APIs
Signed URLsTime-limited query tokenOne-off downloads, email links

Authorization models (how you decide “allowed or denied”)

Section titled “Authorization models (how you decide “allowed or denied”)”

Authentication only establishes who (or which integration) is calling. Authorization decides what they may do on this resource. Models differ in where rules live (token, role table, resource record, policy service) and how fine-grained they are.

ModelGranularityRules live inTypical question
ScopesCoarse capabilities on the tokenIdP / token claims“Does this token include orders:write?”
RBACRole → permission mappingDB / config“Is this user an admin?”
ABACAttributes (tenant, owner, time, …)Code or policy engine“Does resource.owner_id == sub?”
ACLPer-object grant listRow on each resource“Is user A on doc 9’s share list?”
Row-level / tenantEvery row in a tenantQuery + RLS“Can this tenant_id see this row?”
Policy engineCentral reusable policiesOPA / Cedar / IAM“Does policy allow this input document?”
LayeredCoarse at edge, fine in appMultiple layers“Scope OK at gateway; does user own order 12?”

Most production APIs combine scopes or RBAC at the boundary with ownership / tenant checks in handlers or RLS in the database.


When to use: OAuth APIs, public developer platforms, and any token that already carries capabilities (orders:read, billing:write). Best for coarse “may call this class of operation” checks before the handler runs—especially when third-party apps receive delegated access with a fixed scope set.

Example: Token includes scope: "orders:read reports:read". Middleware rejects DELETE /v1/orders/9 if orders:write is missing → 403.

GET /v1/orders
Authorization: Bearer eyJhbG...
# JWT claims: "scope": "orders:read profile:read"

plantuml

Scenarios — why scopes are preferred there

Section titled “Scenarios — why scopes are preferred there”

1. “Connect my GitHub” / third-party app

  • User consents to repo:read only; your API never grants repo:delete on that token.
  • Why scopes: Standard OAuth model; users and auditors understand capability names.

2. Partner API key with attached scopes

  • Key record: partner_id=acme, scopes=["reports:read"].
  • Why: Same pattern as OAuth without full IdP—lookup key → scopes → allow route family.

3. API gateway route policies

  • Route /v1/admin/* requires scope admin:* at the gateway.
  • Why: Block entire surface area before traffic hits fragile admin services.
GapWhy scopes failAdd
“Read my orders” vs “read order #12 I don’t own”orders:read does not encode which rowsResource ownership or tenant filter in handler/DB
Sharing one document with user BScopes are global to the tokenACL on the document
“Manager may approve if amount under $10k”Scopes are not conditional on resource fieldsABAC or policy engine

When to use: Admin panels, internal tools, and SaaS products where users fit named roles (admin, editor, viewer) and permissions change by role assignment, not by editing every resource. Rules are maintained in a role → permission table.

Example: billing_viewer may GET /v1/invoices but not POST /v1/invoices/{id}/refund. Refund requires billing_admin.

plantuml

1. Company admin dashboard

  • Roles: org_admin, member, billing_viewer. Hundreds of users; few role types.
  • Why RBAC: Easy to explain in UI (“Invite as Editor”); permissions centralized in one matrix.

2. Internal ops / support tools

  • Support role may impersonate read-only; only superadmin may change production flags.
  • Why: Audit logs tie actions to role; onboarding is “grant role” not per-endpoint keys.

3. Service accounts

  • Machine principal reporting-worker has role internal_readonly.
  • Why: Same model for humans and jobs; rotate by changing role binding.
SituationBetter choiceReason
Per-document sharing (“share with alice@…”)ACLRoles do not express arbitrary one-off grants on one object.
Third-party OAuth with variable consentScopesIndustry standard for delegated capability lists.
Rule: “owner OR same department OR admin”ABAC / policy engineRBAC roles multiply combinatorially (admin_dept_A, …).

When to use: Rules depend on multiple attributes: resource owner, tenant_id, department, classification, time window, IP, order state. Common in multi-tenant SaaS (“user may edit if order.tenant_id == token.tenant_id and order.status == draft”).

Example:

allow if:
subject.sub == resource.owner_id
OR subject.roles contains "admin"
AND resource.tenant_id == subject.tenant_id

plantuml

1. Multi-tenant SaaS CRUD

  • Every query must match tenant_id from the JWT, never from the client body alone.
  • Why ABAC: Prevents cross-tenant data leaks with one consistent rule.

2. Workflow state machines

  • “Cancel allowed only if status == pending and caller is owner or fulfillment_admin.”
  • Why: Permissions depend on resource state, not just role name.

3. Gradual replacement of hard-coded if chains

  • Policy engine (below) is ABAC at scale; inline checks are ABAC in application code.
SituationBetter choiceReason
Dozens of services need identical rulesOPA / CedarDuplicate ABAC logic drifts between teams.
Simple public API with 3 scopesScopes onlyABAC adds complexity without benefit.
“Who can read file X?” list on the fileACLNatural model is explicit grants on the object.

When to use: Shared resources where access is per object: documents, folders, boards, tickets shared with specific users or groups. Each resource stores (or links to) a list of (principal, permission).

Example: Document doc-9 has ACL: alice:read, bob:read,write. Charlie gets 403 even with global documents:read scope if not on the ACL.

plantuml

Scenarios — why ACLs are preferred there

Section titled “Scenarios — why ACLs are preferred there”

1. Google Drive–style sharing

  • Owner shares one file with external email; others in the org have no access.
  • Why ACL: Grant is on the object; no custom role per collaborator.

2. Support ticket visible to requester + assigned agent

  • Ticket row: ACL or join table ticket_id, user_id, level.
  • Why: Fine-grained without giving support role access to all tickets.
SituationBetter choiceReason
Uniform “all admins see everything in org”RBACACL on every row is heavy to maintain.
API-wide “this key may only call /reports”Scopes at gatewayRoute-level, not per row.
Millions of objects with same tenant ruleRow-level tenant + RBACACL per row does not scale for “everyone in tenant T.”

Row-level security and multi-tenant filtering
Section titled “Row-level security and multi-tenant filtering”

When to use: Every query in a SaaS database must be constrained to the caller’s tenant / org (and often user). Implement in application queries (WHERE tenant_id = ?) and/or RLS (Row-Level Security) in Postgres so even a buggy ORM query cannot cross tenants.

Example (application):

SELECT * FROM orders
WHERE id = $1 AND tenant_id = $current_tenant_from_jwt;

Example (Postgres RLS): Policy tenant_id = current_setting('app.tenant_id') on orders.

plantuml

Scenarios — why row-level / RLS is preferred there

Section titled “Scenarios — why row-level / RLS is preferred there”

1. B2B SaaS with many tenants on one schema

  • Why: One missed WHERE tenant_id in a new endpoint causes a critical breach; RLS is a safety net.

2. Read replicas + ad-hoc SQL tools

  • Analyst tools connect with a role subject to RLS so raw SQL cannot scan all tenants.

3. Microservices sharing one database (discouraged but real)

  • RLS limits blast radius if one service forgets a filter.
SituationStill needReason
“User 5 may edit only their orders”Handler ownership checkRLS on tenant does not distinguish users within tenant.
Complex business rulesDomain logic or policy engineRLS expresses row predicates, not full workflows.
No Postgres / document storeApp-level filtersRLS is a relational DB feature; Mongo needs query filters in code.

When to use: Many services, gRPC + HTTP, admin tools, and compliance need one language for policies; rules change often; security team owns policy repos separately from app releases.

Example (conceptual): API sends JSON to OPA: { "user", "action", "resource" }allow / deny.

plantuml

Scenarios — why a policy engine is preferred there

Section titled “Scenarios — why a policy engine is preferred there”

1. Kubernetes admission + API + batch jobs

  • Same Rego/Cedar policy: “images from approved registry,” “user may delete namespace X.”
  • Why: One audit surface; policies versioned in git.

2. Large bank / regulated environment

  • Why: Security reviews policies without reading every microservice’s if statements.

3. Dynamic attributes from multiple systems

  • Attributes pulled from HR (department), CMDB (env), and request—ABAC too scattered to hard-code.
SituationBetter choiceReason
Single monolith, small teamRBAC + tenant checks in codeOperational cost of OPA cluster and policy CI.
Three scopes and two rolesScopes + RBACSimpler to debug.
Latency-sensitive hot pathCache policy decisions; or in-process rulesExtra network hop to OPA on every request unless optimized.

Layered authorization (gateway + app + database)
Section titled “Layered authorization (gateway + app + database)”

When to use: Almost all non-trivial APIs. Coarse checks early (reject cheaply); fine checks where the resource lives (ownership, state, ACL).

plantuml

Rule of thumb: Gateway knowing orders:read does not prove the caller may read order 12. Always enforce resource-level rules in the service or RLS.


Where authorization checks run in the stack

Section titled “Where authorization checks run in the stack”

When to use each layer: Push cheap, stable rules outward (rate limits, JWT validity, route/scopes). Keep context-rich rules inward (ownership, state machines, ACL on one document). Never rely on the edge alone for “may this user touch this row?”

LayerBest for (authz)Not sufficient alone for
CDN / WAFRate limits, geo block, bot rulesPer-user row access
API gatewayRoute ↔ scope/key, JWT validation, quotasResource ownership
Service middlewareRoles, scopes, tenant from tokenBusiness rules needing DB state
Domain / handlerOwnership, ACL, workflow state— (primary place for fine authz)
Database (RLS)Tenant isolation safety netOAuth scopes, role names

plantuml


When to use: Abuse prevention and global policies—rate limiting per IP/API key fingerprint, geo restrictions, blocking known bad paths, DDoS mitigation. Sometimes JWT validation at edge (e.g. Cloudflare API shield) to drop invalid tokens before origin load.

Example: Block POST from countries you do not serve; cap 1000 req/min per API key id at edge.

1. Public API under scraper attack

  • Why: Origin CPU saved; bad traffic never hits app servers.

2. Static or cacheable GET responses

  • Why: CDN serves cached body; authz for public cache keys only.
LimitationReason
Cannot know order.owner_id without originEdge lacks your database
Per-user row rulesNeeds service or RLS
Treating WAF as sole authzAttackers who pass IP limits still need app-level 403

When to use: Central JWT validation, API key lookup, mTLS termination, route-level authorization (“key X may only hit /v1/reports/*”), quotas, and request routing to microservices.

Example (Kong/Apigee/Envoy): Plugin requires scope: admin for /v1/admin/*; forwards X-User-Id, X-Tenant-Id headers to upstream.

plantuml

Scenarios — why gateway authz is preferred there

Section titled “Scenarios — why gateway authz is preferred there”

1. Many microservices, one front door

  • Why: One place to enforce TLS, JWT, and “this partner key cannot call /v1/payments.”

2. Legacy services without auth middleware

  • Why: Gateway adds authz until services are upgraded.
SituationStill required upstreamReason
GET /v1/orders/12 with orders:readOwnership check in Orders serviceScope does not mean user owns order 12
Compromised gateway configService-level checksMisroute could expose internal paths
Fine ACL per documentHandler + DBGateway does not load document ACL

When to use: Immediately after authn in each service—load principal, enforce roles and scopes, attach tenant_id to request context, reject before controller logic runs.

Example (pseudocode):

@app.middleware
def authz(req):
user = verify_jwt(req)
if "orders:write" not in user.scopes: raise 403
req.state.principal = user

1. Consistent 403 for missing scope across all routes

  • Why: Controllers stay thin; forget one check → still caught if middleware is route-aware.

2. Map API key → partner_id + scopes

  • Why: Same pattern as JWT for B2B keys.
GapAdd in handler
Resource-specific rulesorder.owner_id == user.sub
State-dependent rulesstatus == pending
ACL on shared docLoad ACL table

When to use: Always for rules that need this row’s data—ownership, sharing, workflow state, amount limits, “cannot delete org with active subscription.” This is the source of truth for business authorization.

Example: DELETE /v1/orders/9 → load order → verify order.user_id == jwt.sub OR admin role → verify order.status != shipped → then delete.

plantuml

Scenarios — why handler authz is mandatory

Section titled “Scenarios — why handler authz is mandatory”

1. “Users manage only their own profile”

  • PATCH /v1/users/me vs PATCH /v1/users/{id}—handler ensures {id} == sub unless admin.

2. Idempotent webhook processing

  • Authn was HMAC; authz is “do we accept this event type for this merchant id.”
RiskMitigation
New endpoint forgets checkMiddleware scopes + RLS backstop
Duplicated logic across servicesPolicy engine or shared authz library
Direct DB access bypasses APIRLS on database

When to use: Tenant isolation and last-line defense when application code might bug or a tool connects directly to the DB. RLS policies filter rows by tenant_id (or user) set per connection/session.

Example: SET app.current_tenant = 't-42' before queries; policy on orders allows rows only where tenant_id = current_setting('app.current_tenant').

1. Multi-tenant Postgres SaaS

  • Why: Even if one service ships without WHERE tenant_id, RLS returns zero rows for other tenants.

2. BI read-only role

  • Analysts run SQL with RLS—cannot exfiltrate other tenants.
LimitationReason
Scope names (orders:write)Not stored in SQL—belongs in token/middleware
“Admin may delete any row in tenant”RLS must combine role signal from session variable
NoSQL / event storesUse app filters or separate authorization service

You need…ModelLayer
Third-party “Connect account” with limited powersScopesGateway + middleware
Internal admin rolesRBACMiddleware + handler for exceptions
SaaS tenant isolationRow-level + ABACHandler + RLS
Share one file with external userACLHandler
Same rules on HTTP, gRPC, jobsPolicy engineSidecar or library + handler
“May user X touch this order?”ABAC / ownershipHandler (required)
Product typeTypical authnTypical authz (model + layers)
Public REST + developer portalAPI keys + OAuthGateway: key → routes; token scopes; handler: tenant + ownership
Mobile appOIDC → Bearer JWTMiddleware: scopes; handler: sub owns resource; DB: RLS on tenant_id
Internal microservicesmTLS / service JWTGateway: service identity; handler: “on behalf of” user header + RBAC
B2B webhook receiverHMACHandler: merchant id in payload matches registered id; idempotency (not user RBAC)
Admin dashboardSession / SSORBAC in middleware; sensitive actions audited; optional ABAC (env=prod)
Document collaborationOAuth + BearerScopes coarse; per-doc ACL in handler; search index filtered by ACL

Principles

  • Least privilege — short-lived access tokens, narrow scopes, separate read vs write credentials where possible.
  • Secrets not in URLs — query strings leak via logs, Referer, and browser history; use Authorization header (except deliberate short-lived signed URLs).
  • 401 vs 403 — unauthenticated vs authenticated-but-denied; align with your HTTP status codes page.
  • Validate tokens at the boundary — signature, issuer, audience, expiry; for opaque tokens, introspect or look up in a session store.

Avoid

  • Long-lived tokens in browsers without refresh or rotation strategy.
  • Performing only gateway auth without resource-level checks (defense in depth).
  • Putting tenant id only in the body/query without binding it to the authenticated principal.
  • Using JWT for everything without rotation (stolen token valid until exp unless you add revocation/blocklist).

3. API versioning and backward compatibility

Section titled “3. API versioning and backward compatibility”

Versioning tells clients which contract they are speaking. You do not need a version in the URL if you can evolve compatibly—but you need a policy either way.

Strategies

ApproachProsCons
URL path (/v1/users, /v2/users)Obvious in logs and easy to route.Proliferation of paths; copy-paste between versions.
Header (API-Version: 2024-01-01 or Accept: application/vnd.myapi.v2+json)Clean URLs.Harder to discover from address bar; must document.

Backward compatibility

  • Additive response fields and new endpoints are safe; removing or tightening behavior is not.
  • Deprecation — communicate with Deprecation / Sunset (RFC 9745) headers, docs, and a dated removal plan.
  • Dual-run critical clients on v1 and v2 behind feature flags before turning down v1.

Avoid

  • Silent semantic changes (“status used to mean X, now means Y”) under the same version.
  • Unbounded support for many minor versions without a retirement calendar.

4. Request validation and error model design

Section titled “4. Request validation and error model design”

Validate as early as possible (gateway or first middleware), then again at the domain layer so invariants cannot be bypassed by a different client.

Layers

  1. Syntax — valid JSON, correct Content-Type, size limits.
  2. Schema — types, required fields, string formats (email, UUID (Universally Unique Identifier)), ranges.
  3. Domain — “end date after start date”, “tenant may not access this org”, inventory rules.

Error model

  • One stable envelope everywhere (e.g. RFC 7807 Problem Details: type, title, status, detail, instance).
  • Machine-readable type or code per error class so clients branch without parsing English prose.
  • Field-level errors for validation (errors: [{ "field": "...", "message": "..." } ]) while keeping top-level status aligned with HTTP.

Map HTTP status to outcome: 400 for malformed input, 422 (or strict 400) for well-formed but invalid business validation—pick one convention per API and document it (see HTTP status codes).

Avoid

  • Different error JSON per endpoint.
  • Returning 200 with { "success": false } for failures (breaks caches, monitors, and middleware).

Section titled “5. Pagination, filtering, sorting, and search”

Pagination

StyleUse whenWatch out
Offset / page (?page=2&limit=50)Admin UIs (User Interfaces), small tables, total count needed.Inconsistent pages if data shifts during iteration; expensive OFFSET at scale.
Cursor (?after=opaque)High-churn feeds, mobile infinite scroll.Reversible “previous page” is harder; define cursor stability (tie-breaker).

Always cap limit server-side. Return next / prev links or cursors in the body, not only implicit math.

Filtering and sorting

  • Prefer explicit query params (?status=open&owner_id=eq:42) or a small documented subset; avoid arbitrary SQL (Structured Query Language)-like strings unless you have a safe parser.
  • Sort should require a stable tie-breaker (e.g. sort=-created_at,id) so pagination does not shuffle rows.
  • Search — for heavy or structured queries, POST /search with a JSON body is often clearer than megabyte query strings; document idempotency and caching implications.

Avoid

  • Unbounded queries that scan full tables by default.
  • Sorting only by non-unique columns without a secondary key.

6. Documentation and developer experience (DX)

Section titled “6. Documentation and developer experience (DX)”

Treat documentation as part of the product: onboarding time predicts adoption. DX (Developer Experience) covers docs, examples, and try-it flows.

Deliver

  • Reference — OpenAPI (or equivalent) with realistic examples and all auth flows described.
  • Guides — authentication, pagination, error handling, idempotency, and webhooks in prose.
  • Changelog — breaking vs additive per release; link to migration guides.
  • Try-it — sandbox keys, mock server from OpenAPI, or a minimal Postman/Bruno collection.

DX details

  • Consistent base URL per environment; obvious error when the wrong host is used.
  • Request IDs — accept X-Request-Id from clients or generate one; return it in the response for support correlation.
  • Rate limits — document limits and how to read 429 + Retry-After (when you add them under Reliability).

Avoid

  • Docs that lag the deployed API (generate from the same source as production or fail CI).
  • Examples that use admin-only fields or internal-only headers without labeling them.

7. Browser-side persistence: cookies, storage APIs, and when to use them

Section titled “7. Browser-side persistence: cookies, storage APIs, and when to use them”

Web APIs and SPAs persist state in the browser in several mechanisms. Choices affect security (especially XSS), lifetime, whether data is sent automatically on HTTP requests, and cross-site behavior. This matters for OAuth (PKCE verifiers), session handling, and what you tell integrators never to stash in localStorage.

MechanismTypical lifetimeSent on HTTP requests automatically?JS readable (document.cookie) / script accessCommon API / web use cases
HTTP Cookie (including HttpOnly)Until Max-Age / Expires or session cookieYes — browser attaches Cookie header to matching requests (domain, path, Secure, SameSite)HttpOnly → No script access — mitigates XSS reading the session cookie; non-HttpOnly cookies are script-readableServer session ids, BFF opaque session, “remember preferences” rarely (prefer safer patterns for auth)
sessionStoragePer tab: cleared when tab/window is closedNoYesOAuth PKCE code_verifier for same-origin round-trip; wizard state that must not leak to other tabs
localStorageUntil explicitly clearedNoYesNon-sensitive UX prefs (theme, last-viewed benign id); avoid secrets, JWTs, refresh tokens
IndexedDB / Cache APIPersisted across restarts until clearedNoYes (async APIs)Offline PWAs (Progressive Web Apps), large client-side caches — still hostile to XSS: treat as attacker-readable
In-memory (JS variables only)Tab until full reload/navigation loses SPANoYes (until GC)Short-lived access token to reduce persistence footprint; cleared on reload
Section titled “Cookie attributes that matter (Set-Cookie)”
AttributeEffect
HttpOnlyJavaScript cannot read cookie — reduces token theft via XSS (use for session/session id tied to server auth).
SecureSent only over HTTPS.
SameSite (Same-Site cookie policy; Lax / Strict / None)Lax — cookie not sent on cross-site POST; still sent on some top-level navigations; common default posture. Strict — narrower; less cross-navigation cookie use. None requires Secure — for intentional cross-site cookie use (risky; document carefully). Helps limit CSRF for cookie-based auth when combined with other controls.
Path / DomainLimits which URLs receive the cookie.

Cookies vs Authorization: Bearer — Bearer in JS/header is flexible but readable by XSS. Cookie-based sessions with HttpOnly hide the opaque id from scripts; combine with SameSite, CSRF tokens for unsafe methods, or use SPA + BFF so refresh stays server-side.

NeedPrefer
Long-lived secrets (refresh tokens), pairing user to server session without JS reading idHttpOnly + Secure + SameSite cookie toward your origin only; secrets only on server
Value must survive OAuth redirect on same SPA origin (code_verifier)sessionStorage (or server session if callback hits BFF)
Persist benign UI prefs across visitslocalStorage OK; never store secrets
Largest offline data / blobsIndexedDB (still not for untrusted-sensitive secrets unless you accept device compromise model)
Minimize XSS window for short access tokenMemory variable; renew often; CSP (Content Security Policy) + sanitization

Avoid

  • Refresh tokens, opaque session secrets, API keys, or raw JWTs meant for confidentiality in localStorage/sessionStorage — any XSS leaks them broadly.
  • Relying on cookies for api.example.com from a page on evil.com — cross-origin cookie rules and third‑party cookie deprecation make “cookie everywhere” brittle; explicit Bearer toward API or BFF same-origin cookie patterns are clearer.

1. Caching strategy and cache invalidation

Section titled “1. Caching strategy and cache invalidation”

Cache what is expensive to compute or fetch and safe to serve slightly stale; skip caching per-user secrets or anything where staleness would break a business rule (e.g. an in-flight payment status). The right layer and invalidation strategy follow from two facts about the data: how often it changes and how stale a client can tolerate it being.

Where to cache

LayerGood forWatch out
Client (Cache-Control, ETag)Static assets, rarely-changing reference dataClient controls freshness — you cannot force a purge
CDN (Content Delivery Network) / edgePublic, cacheable GETs shared across usersWrong Vary / auth handling leaks per-user data across users
API gateway / reverse proxyHot read endpoints in front of a slower originOne more place invalidation has to reach
Application cache (Redis, Memcached)Computed aggregates, joined/denormalized viewsExtra infra to run, monitor, and keep coherent across instances
Database (query cache, materialized view)Expensive aggregate queries with a known refresh cadenceRefresh lag is itself a staleness source to document

Each layer only exists to short-circuit the one behind it — a hit returns immediately, a miss falls through to the next, slower layer:

mermaid

HTTP-native caching

  • Cache-Control: max-age=…, s-maxage=… — the primary TTL (Time To Live) signal; s-maxage lets shared caches (CDN) hold longer than the browser does.
  • ETag / If-None-Match — revalidate cheaply: the server returns 304 Not Modified with no body when the resource hash is unchanged.
  • Last-Modified / If-Modified-Since — coarser revalidation when a hash is impractical to compute.
  • Vary — tells a shared cache to key its stored responses by a request header too, not just the URL. Vary: Authorization makes the cache key (URL, Authorization value), so a request with Bearer A and one with Bearer B get two separate stored entries. Without it, a cache keyed only on the URL will serve one user’s cached response to a different user. Downside: since every user’s Authorization/Cookie value is different, Vary-ing on them means the shared cache basically stops getting cross-user hits at all — for personal responses, Cache-Control: private (browser-only, no shared cache stores it) is usually the more honest fix than relying on Vary.
  • stale-while-revalidate — serve the stale copy immediately while refreshing in the background; hides origin latency, at the cost of briefly serving old data on every refresh cycle.

Invalidation strategies

StrategyHow it worksTrade-off
TTL expirationEntry expires after a fixed window; no explicit invalidation neededZero invalidation machinery, at the cost of a staleness window that exists even right after a write
Cache-aside, invalidate-on-writeApp deletes/updates the cache key synchronously right after the write commitsCache stays close to correct, at the cost of every write path having to remember the cache exists
Write-throughWrite goes to cache and store together, in the same requestReads are always fresh, at the cost of write latency = cache write + store write
Event-driven invalidationA domain event (e.g. order.updated) fans out to purge affected keysDecouples writers from cache topology, at the cost of an async delivery path that can lose or delay the purge
Versioned keys (cache:v3:order:123)Bump a version/namespace on schema or logic change instead of purgingAvoids a purge stampede, at the cost of old versions lingering until their own TTL

Event-driven invalidation, concretely — the writer publishes a domain event (e.g. ProductPriceChanged{id}) to a broker (Kafka, SNS/SQS, Redis pub-sub) instead of purging any cache itself. One or more independent consumers subscribe to that event and purge their own cache/CDN/search-index entries. The write path never has to know which caches exist downstream — a new consumer can start subscribing later without any change to the write path.

Versioned keys, concretely — two variants:

  • Global bump (v3:…v4:…) when a code/logic change, not a data change, makes old entries wrong — e.g. fixing a computed-field bug. New reads use the new prefix; old entries just age out via TTL, no bulk purge needed, and old/new app versions can coexist safely during a rolling deploy.
  • Per-entity version (order:{id}:{updated_at}), derived from the row’s own version or timestamp — a write changes updated_at, so the next read computes a different key automatically. This needs no explicit delete at all: the key itself encodes staleness.

The most commonly tested pattern is cache-aside reads paired with invalidate-on-write:

mermaid

Where this breaks

  • Cache stampede — a hot key expiring under high concurrency sends every in-flight request to the origin at once.

    • Request coalescing (single-flight) — the first miss takes a lock (in-process mutex, or a distributed lock via SET key val NX PX 5000 in Redis); it alone fetches from the origin while every other concurrent request blocks on that same in-flight result. Net effect: exactly one origin call per key, regardless of concurrency.
    • Jittered TTL60s + random(0, 10s) instead of a fixed 60s, so keys populated around the same moment don’t all expire in the same instant and stampede together.
    • stale-while-revalidate — keep serving the stale value to everyone while exactly one background job refreshes it; from the client’s side there is never a “miss” to stampede on at all.
    • Cache warming — proactively refresh known hot keys (top-N products, homepage data) before they’d expire under load, so the risk never materializes for your hottest keys in the first place.

    mermaid

  • Invalidation message loss — event-driven purge depends on delivery; a dropped message leaves a stale entry with nothing to correct it until the next unrelated write or TTL. That’s why you still put a backstop TTL on entries even when you have active invalidation — a TTL you’re not relying on to do the invalidating, only to cap how stale things can get if the active path fails. Example: purge normally lands within ~200ms via Kafka, but every entry also carries a 6-hour TTL, so a consumer that’s down for an hour still can’t leave anyone more than 6 hours stale.

  • Multi-instance / multi-region incoherence — this only bites when each server keeps its own in-process cache instead of a shared one. Deleting a key on server A does nothing to server B or C — each holds its own copy in memory and keeps serving it until its own TTL expires.

    • Accept the staleness window — keep local TTLs short enough that being wrong for a few seconds is tolerable; no coordination needed.
    • Move to one shared cache tier — every instance reads/writes the same Redis/Memcached cluster, so one DEL invalidates everyone at once, at the cost of a network hop on every access.
    • Pub/sub invalidation broadcast — keep local in-process caches for speed, but broadcast a purge message on write; each instance deletes the key from its own memory on receipt. This reintroduces the message-loss risk above, so pair it with a backstop TTL too.
    • Multi-region specifically — cross-region replication/purge takes real time, not zero. Mitigate with read-your-own-writes region pinning, shorter TTLs at edge/CDN layers, or an explicit CDN purge call (which itself takes seconds to fan out globally) — or simply document the bound (“cross-region reads may lag writes by up to N seconds”) instead of engineering it away.

Avoid

  • Caching a mutable, per-user response at a shared layer without a correct Vary — this is how one user’s cached response gets served to another.
  • Relying on TTL alone for data with a hard consistency requirement (“did the payment go through”) — the TTL window is exactly the window where the cached answer can be wrong.
  • Deleting a key on invalidation with no stampede protection — every request that misses at the same instant recomputes cold and can take the origin down.
  • No invalidation strategy at all (“cache everything with a TTL and hope”) — silently means every write has an undocumented staleness window somewhere downstream.

Rate limit to protect shared capacity from any one caller and to keep usage fair across tenants. The right algorithm and granularity follow from two facts: how bursty legitimate traffic is allowed to be, and what you’re protecting (your own backend, or a slower downstream dependency you call on their behalf).

Algorithms

AlgorithmHow it worksTrade-off
Fixed window counterCount requests in a fixed bucket (e.g. per minute); reset to 0 at each boundarySimple to reason about and cheap to store, at the cost of allowing up to 2x the limit in a burst that straddles a window boundary
Sliding window logStore every request’s timestamp, count how many fall inside the trailing windowExact limit enforcement, at the cost of storing and scanning a timestamp per request
Sliding window counterWeighted blend of the current and previous fixed-window countsSmooths out the boundary-burst problem, at the cost of being an approximation rather than an exact count
Token bucketA bucket holds tokens up to a capacity and refills at a fixed rate; each request consumes one token and is rejected if the bucket is emptyAllows controlled bursts up to the bucket size while still enforcing a steady average rate — the usual default
Leaky bucketRequests queue and drain out at a fixed rate; a full queue rejects new requestsSmooths bursts into a steady output rate, at the cost of added latency for anything sitting in the queue

Tools that implement each one, and where it fits best

AlgorithmUsed by / implemented inBest enforced at — and why
Fixed window counterThe Redis INCR + EXPIRE recipe (Redis’s own rate-limiting cookbook example); GitHub’s primary API rate limit (resets at a fixed timestamp each hour); most default configs in django-ratelimit / express-rate-limitService / application, for simple “N per hour/day” quotas where low storage cost matters more than boundary precision. Avoid it for anything security-sensitive (login, OTP) — the boundary trick is an exploitable doubling, not just a cosmetic inaccuracy
Sliding window logThe Redis sorted-set recipe (ZADD + ZREMRANGEBYSCORE + ZCARD) — Redis’s canonical “exact” limiter patternService / application only, and only for low-volume, high-value limits (login attempts, password resets, payment retries) where exactness is worth the per-request storage cost. Never at CDN/edge scale — storing a timestamp per request doesn’t survive that volume
Sliding window counterCloudflare’s rate limiting (publicly documented as a sliding-window approximation blending the current and previous fixed windows); Kong’s rate-limiting plugin’s “sliding window” policyCDN/edge or API gateway — it’s the algorithm edge providers reach for because it’s nearly as cheap as a fixed window (two counters, not a log) but doesn’t have the boundary-doubling flaw, which matters at the traffic volumes edge providers operate at
Token bucketAWS API Gateway usage plans (explicitly token bucket: burst + steady rate); Stripe’s API rate limits; Envoy’s local rate limit filter; Kong’s default policy; the GCRA variant used by Lyft’s ratelimit service and Cloudflare (a cheap, single-timestamp token-bucket equivalent)API gateway, as the general-purpose default for public APIs — it’s the one place you want to deliberately allow legitimate bursty clients some slack while still capping the sustained rate, and gateways are where you already know the caller’s identity/plan tier
Leaky bucketNGINX’s ngx_http_limit_req_module (NGINX’s own docs describe it as leaky bucket); queue-based smoothing in front of a fragile downstream (SQS/RabbitMQ with a fixed-rate consumer)Reverse proxy / gateway, for smoothing bursty client traffic into a steady stream a backend can actually handle — or as an outbound pattern in front of a downstream dependency you call, where the added queueing latency is more acceptable than it would be on a live inbound user request

Each algorithm’s decision flow

The behavior that actually matters for each algorithm — where it resets abruptly, where it stays smooth, where it rejects — is easier to follow as a flow than described in prose:

diagramsnet

diagramsnet

diagramsnet

diagramsnet

diagramsnet

Where to enforce

LayerGood forWatch outUsed in practice
CDN / edgeBlocking abuse-scale traffic before it reaches your infraCoarse — usually keyed by IP, not by account or API keyCloudflare Rate Limiting Rules, AWS WAF rate-based rules, Akamai App & API Protector, Fastly
API gatewayOne shared enforcement point for per-key/per-tenant quotas, before any backend does workIf the gateway runs as multiple instances, they all need to share one counter storeKong (rate-limiting plugin), Envoy (local/global rate limit filter), AWS API Gateway usage plans, Apigee, Tyk, NGINX (limit_req/limit_conn)
Service / applicationFine-grained limits tied to business rules (e.g. “5 password resets per hour per account”)Enforcing the same key again here duplicates work the gateway may have already doneRedis (INCR/EXPIRE, or a Lua script) as the shared counter; language-level libraries on top of it — Bucket4j (Java), resilience4j RateLimiter (Java), Guava RateLimiter (single-instance only), express-rate-limit (Node), django-ratelimit (Python)

HTTP-native signaling

  • 429 Too Many Requests — the status clients should branch on, distinct from a generic 4xx/5xx.
  • Retry-After — tells the client exactly when to try again instead of making it guess (and possibly guess too aggressively).
  • RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset (or the vendor-prefixed X-RateLimit-* equivalents) — let a well-behaved client back off before it gets a 429 at all, by watching how much budget it has left.

Where this breaks

  • Distributed enforcement — this only bites when the gateway or service runs as more than one instance and each instance counts requests in its own local memory. The real limit then becomes (per-instance limit × instance count), not the number you configured, because no single instance ever sees the full picture. Fix by moving the counter to a shared store (Redis INCR + EXPIRE, or a dedicated rate-limiting service), at the cost of a network round trip on every request and that store now needing to be fast and available.
  • Synchronized reset stampede — if every client’s window resets on the same wall-clock boundary (e.g. daily quotas at midnight UTC), everyone who was throttled retries at the same instant, which looks exactly like the cache stampede pattern above. Mitigate by staggering each client’s own window start (e.g. from their first request) instead of aligning everyone to the same clock boundary.
  • Non-atomic counter updates — a naive “read count, check limit, write count+1” done as three separate steps lets two concurrent requests both read the old count before either writes, so more requests get through than the configured limit under load. Use an atomic increment (INCR in Redis, or a Lua script for check-and-increment in one step) instead of a manual read-then-write.

Avoid

  • A single global limit with no per-key granularity — one noisy or abusive caller exhausts the budget that every other caller was relying on.
  • Returning 429 with no Retry-After — the client has no signal for when to retry, so it either hammers immediately or backs off far more than necessary.
  • Rate-limiting the gateway and the service independently on the same key without coordinating the numbers — a caller can get a confusing mix of “allowed at the gateway, rejected at the service” for what looks like the same request pattern.

  1. Idempotency and retry semantics
  2. Timeouts, retries, and circuit breaking
  3. Consistency and concurrency control
  1. Webhooks and asynchronous APIs
  2. Testing and quality strategy
  3. Monitoring, logging, and tracing
  1. API governance and lifecycle management
  2. Multi-tenancy, privacy, and compliance