Production Reference

API Security Cheatsheet

Operational reference for securing HTTP APIs in production. JWT verification patterns, OAuth2 flows, secure HTTP headers, mTLS, webhook signing, and rate-limiting that holds up against the OWASP API Top 10.

Command-firstProduction notesSecurity warningsHardened patterns

JWT verification (the only correct way)

4 commands
jwt.verify(token, key, { algorithms: ["RS256"] })

Always pin algorithms in an allowlist. Without it, libraries trust the alg in the token header — a known attack vector.

Warning: Without algorithms allowlist, an attacker can swap RS256 → HS256 with the public key as HMAC secret and forge tokens.

jwt.verify(token, key, { issuer, audience, clockTolerance: 5 })

Validate iss, aud, and clock skew. clockTolerance handles cross-machine drift.

Production note: Always validate aud — a token minted for service A must not be accepted by service B. iss anchors which IdP issued the token.

JWKS rotation: cache JWKS for 1h, force refresh on kid miss

When a kid (key ID) in a JWT header isn't in your cache, refresh JWKS once before failing.

Production note: Use libraries with JWKS support (jose, PyJWT 2.x, Auth0's jwks-rsa). Manual cache management is a footgun.

Token revocation: short TTLs + denylist for emergency revoke

JWT cannot be revoked once issued. Mitigate with short TTLs (5-15 min access tokens) and refresh tokens.

Warning: Long-lived JWTs (>1 hour) without refresh flow are an outage waiting to happen — once leaked, they're valid until expiry.

OAuth2 / OIDC patterns

5 commands
response_type=code & PKCE (S256)

Authorization Code flow with PKCE. The default for SPAs and mobile apps. PKCE prevents authz code interception.

Production note: PKCE is now recommended for confidential clients too (OAuth 2.1 draft). No reason to skip it.

redirect_uri: exact-match against allowlist

Never use prefix or substring matching on redirect_uri. Parse the URI and compare host:port:path exactly.

Warning: startsWith("https://app.example.com") matches "https://app.example.com.attacker.com" — leak the auth code to the attacker.

state parameter: random per-request, validated on callback

CSRF protection for the auth flow. Tie state to the user's session.

Production note: Use a HMAC-signed state with timestamp so you can verify "this state was issued by us, recently".

refresh_token: rotate on use, detect reuse

Issue a new refresh token on each refresh; revoke the entire refresh chain if an old one is reused.

Production note: Refresh-token reuse detection catches stolen refresh tokens — both attacker and legit user end up logged out, but the attacker can no longer mint access tokens.

OIDC: validate id_token signature + claims (sub, aud, iss, exp, iat, nonce)

OIDC id_tokens carry user identity. Validate every claim — exp prevents replay, nonce binds to the auth request.

Warning: Skipping nonce validation enables a token-substitution attack across sessions.

Security headers (every response)

6 commands
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Force HTTPS for 1 year, including subdomains. Preload submits to browser HSTS lists.

Warning: HSTS is sticky. Test on a subdomain before enabling site-wide; rolling back requires every visiting browser to expire the policy.

Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-...'

Restrict where scripts/styles/images can come from. Use script hashes or nonces, never 'unsafe-inline' or *.

Production note: Roll out with Content-Security-Policy-Report-Only first to capture violations without blocking; promote once clean.

X-Frame-Options: DENY (or CSP frame-ancestors 'none')

Prevent clickjacking via iframe embedding. CSP frame-ancestors supersedes XFO in modern browsers.

Production note: Send both for maximum compatibility — older browsers ignore frame-ancestors.

X-Content-Type-Options: nosniff

Prevent browsers from MIME-sniffing responses. Mitigates a class of polyglot file attacks.

Production note: Pair with explicit Content-Type on every response. Static-asset pipelines should set both.

Referrer-Policy: strict-origin-when-cross-origin

Limit Referer header to origin (no path) when navigating cross-origin. Default in modern browsers — set explicitly anyway.

Production note: Aggressive policies (no-referrer) can break analytics; strict-origin-when-cross-origin is the production sweet spot.

Permissions-Policy: camera=(), geolocation=(), microphone=()

Disable powerful browser APIs your app doesn't use. Limits the blast radius of XSS.

Production note: Audit which features your app actually uses. Most APIs need none of these — disable them all.

CORS (the rules that matter)

4 commands
Access-Control-Allow-Origin: <exact origin> (echoed from request)

For credentialed requests, echo back the request Origin only if it's in your allowlist. Add Vary: Origin so caches don't mix responses.

Warning: Browsers reject Access-Control-Allow-Origin: * combined with Allow-Credentials: true. Use exact origins.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE

Methods your endpoint supports. Don't list methods you don't implement.

Production note: Wildcard methods are NOT a thing in CORS — list each one explicitly.

Access-Control-Allow-Headers: Authorization, Content-Type

Headers the browser may send on cross-origin requests. List only what your API needs.

Production note: Authorization typically needs to be allowed for token-based APIs; never wildcard headers.

Access-Control-Max-Age: 600

Cache preflight for 10 minutes. Reduces preflight overhead for chatty APIs.

Warning: Browsers cap this (Chrome 2 hours, Firefox 24 hours). Don't set huge values expecting them to be honored.

mTLS for service-to-service

3 commands
tls.Config{ Certificates, ClientCAs, ClientAuth: tls.RequireAndVerifyClientCert }

Server config that requires client certs and validates against ClientCAs.

Production note: Authorize the peer's identity (SPIFFE ID, CN, or SAN) after the handshake — "valid cert from our CA" is not authorization.

tlsconfig.MTLSServerConfig(source, source, authorizer) // go-spiffe

go-spiffe SDK builds an mTLS config that reads SVIDs from the live source. Auto-rotation, no app code.

Production note: Always use the SDK's tlsconfig helpers — manual GetX509SVID() captures a snapshot and breaks rotation.

Authorization: spiffe://corp.example.com/ns/X/sa/Y allow if peer.path startswith "/ns/X/"

Authorize peers by SPIFFE ID prefix, not "any cert from our CA". Push the policy into OPA/Rego for clarity.

Warning: Substring matching on SPIFFE IDs is bypassable. Always parse the URI and compare exact trust domain + path prefix.

Webhooks (signed by sender)

4 commands
X-Hub-Signature-256: sha256=<HMAC>

GitHub-style webhook signature header. HMAC-SHA256 over the raw request body, with a shared secret.

Production note: Always verify against the *raw* body — JSON parsing changes whitespace and breaks the signature.

crypto.timingSafeEqual(received, expected)

Constant-time signature comparison. Prevents a timing side-channel that leaks the signature byte-by-byte.

Warning: Plain == on strings short-circuits; an attacker can measure response time to forge signatures.

X-Webhook-Timestamp + tolerance window (300s)

Reject requests outside a small time window to prevent replay. Stripe uses ±5 minutes.

Production note: Sign the timestamp into the HMAC so an attacker can't adjust it.

Idempotency: dedupe by signed event ID

The same event may be delivered multiple times. Dedupe on a unique ID provided by the sender.

Warning: Without dedupe, double-charges and double-side-effects are inevitable on networks with retries.

Rate limiting (that survives spoofed headers)

3 commands
Trusted edge: strip incoming X-Forwarded-For; emit a sealed header

Configure your CDN/ingress to strip client-supplied X-Forwarded-For and emit a fresh, trusted header.

Warning: Trusting client X-Forwarded-For lets attackers cycle the value to get a fresh rate-limit bucket per request.

Per-token + per-IP buckets, separately

Apply both — per-token catches authenticated abuse, per-IP catches unauthenticated abuse from a botnet.

Production note: Token-based limits should be more generous than anonymous limits. Tie limits to billing tier where applicable.

Backend: 429 + Retry-After header

When limited, return 429 with Retry-After so well-behaved clients back off.

Production note: Add a Retry-After-Reset header with the absolute reset time for client UX. Echo back rate-limit headers (X-RateLimit-Limit/Remaining/Reset).

Hardened patterns

Common misconfigurations

The unsafe pattern, the replacement, and the reason the two are not equivalent in production.

FIXReview

Risky

// Verify a JWT
const decoded = jwt.verify(token, getKey());

Hardened

// Verify a JWT (correct)
const decoded = jwt.verify(token, getKey(), {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  clockTolerance: 5,
});

Why it matters: Without algorithms, the library trusts the token's alg header — letting an attacker forge tokens by switching to HS256 with the public key as HMAC secret. Without iss/aud, a token minted by a different IdP or for a different service may be accepted.

FIXReview

Risky

// Webhook signature check
function verify(sig, expected) {
  return sig === expected;
}

Hardened

// Webhook signature check (constant-time)
function verify(sig, expected) {
  const a = Buffer.from(sig, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length &&
    crypto.timingSafeEqual(a, b);
}

Why it matters: String == short-circuits at the first mismatched byte; an attacker can measure response time to discover the signature byte by byte. timingSafeEqual compares in constant time. The provider docs (Stripe, GitHub) explicitly recommend this.

FIXReview

Risky

app.use(cors({
  origin: '*',
  credentials: true,
}));

Hardened

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
  origin: (origin, cb) => {
    if (!origin || allowedOrigins.includes(origin)) cb(null, true);
    else cb(new Error('CORS blocked'));
  },
  credentials: true,
}));

Why it matters: Browsers reject the * + credentials combination outright. Even if they didn't, every site could read authenticated user data via cross-origin fetch. Echo back specific allowed origins; combine with Vary: Origin so caches don't leak responses.

Go deeper