Checks one running service: its API, failure handling, limits, and security. This page holds the cards for the server context. The Service audit page lists every card. Report each check as PASS, FAIL, or N/A with file:line evidence. A FAIL carries the card's severity. Any blockFAIL means request changes. A should-fix or discussFAIL goes in the review but does not block.
Verdict: request changes - change, function - 2 block, 1 should-fix FAIL, 1 PASS, 14 N/A
FAIL [block] chg-expand-contract - src/a.py:42 - renames the column and drops the old one in one migration
FAIL [block] svc-sqli-authn - src/db.py:88 - proven: builds the WHERE clause with an f-string from request.args
FAIL [should-fix] chg-one-concern - src/b.py:7 - a logging rewrite rides along with the bug fix
PASS chg-breaking-change-taxonomy - src/c.py:15 - no signature or return shape changes
N/A: 14 cards, applies_when does not hold
Filters
Concerns Topics
A card appears under each concern or topic it lists, so a group view can repeat a card.
Checklist
CardSeverityConcernsRule
svc-account-linkingblocksecurityLink a federated identity to an existing account only after proving control of both, and key the link on the pair iss and sub, never on an unverified email or another profile claim.
svc-api-cache-controlshould-fixperformancesecuritySet an explicit Cache-Control on every response a cache may store, use private or no-store for user-specific data, add Vary for any request header that changes the body, keep the origin's Cache-Control authoritative at the edge (no minimum TTL or force-cache override), and decide cacheability by the response Content-Type, not the URL extension.
svc-api-conditional-writesshould-fixcorrectnessReturn an ETag on reads and honour If-Match on writes, answering 412 when the entity tag is stale.
svc-api-contract-testsshould-fixcompatibilitytestingRun a contract test for each cross-service call, derived from the consumer's real requests, and fail the provider's build when it breaks one.
svc-api-deprecation-sunsetshould-fixcompatibilityBefore removing an endpoint, mark it deprecated in the spec and send Deprecation and Sunset headers.
svc-api-documented-errorsshould-fixcorrectnesscompatibilityDocument every error status each operation can return, with a schema and one example.
svc-api-error-code-stableshould-fixcompatibilityGive each error a stable machine-readable code that callers branch on, and never make them parse the message text.
svc-api-key-scopeshould-fixsecurityIssue each API key with the narrowest scopes and a bounded lifetime, and rotate or revoke it after any exposure.
svc-api-naming-consistentdiscussmaintainabilityUse one casing convention per location (fields, query parameters, headers, paths) across the whole API, and state it in the API guide.
svc-api-problem-jsonshould-fixcompatibilityReturn errors as application/problem+json with a status that equals the HTTP status, and leave stack traces and internal detail out.
svc-api-retry-aftershould-fixreliabilitySend Retry-After on every 429 and 503, and set it no earlier than the time capacity returns.
svc-api-specific-status-codesshould-fixcompatibilityReturn the most specific status code that fits: 201 with Location for a create, 405 with Allow for a wrong method, and a 4xx or 5xx for any failure.
svc-api-versioning-policyshould-fixcompatibilityWrite down one versioning scheme for the API, and ship no breaking change inside a version it has published.
svc-auth-throttle-generic-errorsblocksecurityLimit failed attempts per account, and return the same message and status code whether or not the account exists.
svc-authn-by-defaultshould-fixsecurityRequire authentication on every route by default, and mark each public route by name.
svc-authz-central-deny-defaultblocksecurityRoute every request through one authorization layer that denies when no rule allows it.
svc-bound-client-controlled-sizesshould-fixsecurityCap every client-controlled size and count on the server: body bytes, string length, array items, page size and batch size.
svc-cache-key-completenessblocksecurityKey every request input that can change the response, and strip any other request value before it reaches a cached body.
svc-cors-allowlistshould-fixsecurityAllow cross-origin access only to a fixed list of named origins, and never echo the request Origin header back unchecked.
svc-csp-strict-script-srcshould-fixsecuritySend a Content-Security-Policy whose script-src uses a per-response nonce or script hashes, with object-src 'none' and base-uri 'none'.
svc-csrf-state-changingblocksecurityRequire a CSRF token (or an equivalent header check) on every cookie-authenticated request that changes state, and never change state on GET.
svc-decompression-capshould-fixsecurityperformanceCap the decompressed output of every decoder, and set the cap on the output rather than the compressed input.
svc-dlqshould-fixreliabilityGive every consumer queue a dead-letter queue and keep a way to replay it after the fix. On an ordered queue, key the group or session to the unit that must stay ordered, so a poison message holds only its own unit.
svc-fail-closed-defaultsshould-fixsecurityShip every security control switched on, with an empty allow-list denying everything.
svc-host-header-trustblocksecurityBuild absolute URLs from configured values, answer only for a Host on an allowlist, and take X-Forwarded-* values only from a trusted proxy.
svc-idempotency-keyblockreliabilitycorrectnessMake every retryable write idempotent: the caller sends a unique key, and a retry with the same key never applies the write twice.
svc-invalid-input-is-4xxshould-fixcorrectnessreliabilityCheck each caller-supplied value against its allowed domain (range, allowed set, an id that must exist, an encoding the code can handle) at the boundary, and answer a failed check with a 4xx, never a 5xx.
svc-list-endpoint-paginatesshould-fixperformancePaginate every list endpoint, with a default page size and a server-side maximum.
svc-mfa-step-upshould-fixsecurityRequire a fresh authentication with an already-bound factor before sensitive actions and before adding, replacing or removing an MFA factor; an existing session alone is not enough.
svc-no-secrets-in-urlshould-fixsecuritySend tokens, keys, passwords, and personal data in a header or the request body, never in the path or query string.
svc-oauth-code-flow-bindingblocksecurityFinish the authorization-code flow with PKCE and a challenge method that keeps the verifier out of the authorization request (S256), send the exact registered redirect_uri (a native app on the loopback interface may vary the port), and reject the response unless this transaction's state or OIDC nonce matches and the ID token's signature, iss, aud and exp check out.
svc-password-resetblocksecurityMake the reset token single-use and short-lived, and on success revoke every session.
svc-rate-limit-quotashould-fixsecurityreliabilityEnforce a per-tenant quota on shared APIs and queues.
svc-request-framingblocksecurityReject a request that carries both Transfer-Encoding and Content-Length, or remove one field at the first hop, and parse header framing strictly rather than with a leniency flag.
svc-saga-stepsshould-fixreliabilitycorrectnessMake steps after the pivot idempotent and retryable, and make every compensation idempotent with a failure path.
svc-saml-assertion-bindingblocksecurityVerify the XML signature over the assertion you read, with the IdP's configured key rather than a key carried in the response, then require the assertion's Audience and its bearer SubjectConfirmationDataRecipient to name this SP, the response Destination to be this endpoint, InResponseTo to match your request (absent for an IdP-initiated response), NotOnOrAfter to be current, and the assertion ID to be unseen. These are one step, verify the assertion, and each check is cited.
svc-session-lifecycleblocksecurityIssue a new session ID at login, and set Secure, HttpOnly and SameSite on the session cookie.
svc-sqli-authnblocksecurityPass untrusted data to queries as bound parameters, never by string concatenation or formatting.
svc-tenant-id-in-telemetryshould-fixsecurityobservabilityPut the verified tenant id on every tenant-scoped log, metric and audit record.
svc-tls-modern-onlyshould-fixsecurityAccept only TLS 1.2 and TLS 1.3 on servers.
svc-upload-validate-storeblocksecurityAllowlist the file type, cap the size, and store each upload under a generated name where the server will not execute it.
svc-webhook-replay-windowshould-fixsecurityReject an inbound signed message whose creation time is outside a short tolerance, and sign that time with the body so it cannot be swapped.
Tool gate: what a linter or CI check could enforce instead of a reviewer.
Cards
svc-account-linking
blockstablesecurity
Applies when: The service links a federated identity to an existing account, or looks up an account by an email or profile claim.
Rule. Link a federated identity to an existing account only after proving control of both, and key the link on the pair iss and sub, never on an unverified email or another profile claim.
Why. An attacker who registers a victim's address first can wait for the victim to press "Sign in with Google" and inherit the account through an automatic merge. The same bug appears when an IdP asserts an address it never verified, or one that changed owner since it did. An authenticated session on the existing account, plus a freshly authenticated federated identity, proves both sides. iss and sub together are the only stable key; email, preferred_username and name can be reused or reassigned, so they are a lookup hint at best.
FAIL
if user.email == claims["email"]:
user.identities.append(claims["sub"]) # merges on an unverified claim
PASS
if session.user is None: # control of the existing account
raise PermissionDenied
if not (claims["iss"] and claims["sub"]): # a stable federated key
raise PermissionDenied
link(session.user, key=(claims["iss"], claims["sub"]))
# email stays a display field, never the lookup key
Counterpoint. "Users expect the Google button to find the account with their email." Find it by a verified address if you like, then stop and require the password or an authenticated session before linking. A verified flag is not always present, and an address verified at signup can belong to someone else today, so verify it yourself when the IdP does not. Treating the match as proof is what the attack needs. Google says that where it is authoritative for the address (a @gmail.com address, or email_verified with hd set) you may skip a password challenge. That settles who owns the address, not who controls the local account, so an IdP that is authoritative for the domain may be trusted for the lookup, and the merge still needs the authenticated local session when the local address was never verified.
Applies when: A service returns responses that a browser, CDN, gateway or client library may cache.
Rule. Set an explicit Cache-Control on every response a cache may store, use private or no-store for user-specific data, add Vary for any request header that changes the body, keep the origin's Cache-Control authoritative at the edge (no minimum TTL or force-cache override), and decide cacheability by the response Content-Type, not the URL extension.
Why. With no directive, a cache may guess a lifetime from Last-Modified. A shared cache may then serve one user's response to another, or serve stale data for hours after a write. A dynamic page requested at /account/x.css is the same failure from the cache side: a rule that decides by extension stores a private page, and an edge rule that overrides no-store keeps it. The origin and the cache must also read the path the same way. When they do not, a path the origin treats as private can look like a static file to the cache.
FAIL
GET /me/orders -> 200
(no Cache-Control, no Vary; body differs per Authorization)
edge: GET /account/x.css -> stored for 1 hour
edge: minimum TTL 3600 s, so origin Cache-Control: private, no-store is overridden
PASS
GET /me/orders -> 200 Cache-Control: private, no-store
GET /plans -> 200 Cache-Control: public, max-age=300
Vary: Accept-Language
edge: minimum TTL 0, origin headers kept
edge: /account/x.css not stored; Content-Type: text/html does not match .css
Counterpoint. "Our CDN bypasses the cache for logged-in users." That config is a second place to get wrong, and it does not cover the browser. A cache rule that sets a minimum TTL, or a force-cache mode, can still beat the origin header. Check the specific policy value, and keep secrets out of cacheable URLs even when the header is right.
Tool gate: Integration test or header lint that rejects a 200 GET without Cache-Control.
Applies when: Two callers can update the same resource through PUT, PATCH or DELETE.
Rule. Return an ETag on reads and honour If-Match on writes, answering 412 when the entity tag is stale.
Why. A read-modify-write over HTTP is a race. The second writer overwrites the first without knowing it, and no error appears anywhere.
FAIL
GET /docs/4 -> 200 {title: "A"}
PUT /docs/4 {title: "B"} -> 200 # overwrites a change made after the GET
PASS
GET /docs/4 -> 200 ETag: "v7"
PUT /docs/4 If-Match: "v7" -> 200 ETag: "v8"
PUT /docs/4 If-Match: "v7" -> 412 Precondition Failed
Counterpoint. "Last write wins is fine here." For a log or a counter it is; say so in the spec. For a document, a profile or a config that two people edit, it loses work. The card applies to shared resources that people edit.
Tool gate: Integration test that sends a write with a stale If-Match and expects 412.
Applies when: A service calls, or is called by, another service owned by a different team or deployed on its own schedule.
Rule. Run a contract test for each cross-service call, derived from the consumer's real requests, and fail the provider's build when it breaks one.
Why. A provider's own tests check what the provider thinks callers need. A spec check says what could happen, not what the consumer relies on. Only a check built from the consumer's usage catches the removed field before deploy, without a full-system end-to-end run.
FAIL
billing calls GET /customers/{id} and reads "vat_id"
customers team removes "vat_id"; its tests pass; billing breaks in production
PASS
billing CI publishes a contract: GET /customers/{id} -> body has "vat_id"
customers CI verifies every published contract and fails on the removal
Counterpoint. "End-to-end tests cover it." They are slow, need every service running, and run late. Contract tests run per service in minutes. Pact is one way; a consumer-written example set replayed against the provider also passes. For services deployed together from one repo, mark the card N/A.
Tool gate: Contract test run in CI for both sides, and a gate that blocks deploy when a consumer's contract fails against the provider.
Applies when: A service retires an endpoint, field, version or parameter that outside callers use.
Rule. Before removing an endpoint, mark it deprecated in the spec and send Deprecation and Sunset headers.
Why. The Sunset date must not be earlier than the Deprecation date (RFC 9745). Callers cannot read your roadmap. A removal with no signal in the responses is an outage for whoever has not read the changelog. Counting calls that carry the headers also tells you when removal is safe.
FAIL
git: delete GET /v1/orders/{id}/summary
responses before removal: no header, spec not marked deprecated
Counterpoint. "Our only callers are internal." Then the call count with the header gives you the list of owners to ask, and removal is cheap. chg-expand-contract covers the order of steps; this card covers what the response tells the caller. Both headers describe one resource, so send them on each deprecated route, not only on the API root.
Tool gate: Middleware that adds both headers for routes tagged deprecated; a spec linter on deprecated: true without a sunset date; a dashboard counting calls that carry the headers.
Applies when: A service has an OpenAPI description, or callers depend on its error responses.
Rule. Document every error status each operation can return, with a schema and one example.
Why. Callers write error handling from the spec. An operation that lists only 200 teaches them nothing about 404, 409, 422 or 429, so they find out in production.
/orders/{id}:
get:
responses:
"200": {description: ok}
"404": {$ref: "#/components/responses/Problem"} # has an example
"429": {$ref: "#/components/responses/RateLimited"}
Counterpoint. "A shared default response covers everything." OpenAPI allows default, but it hides which codes carry which meaning. List the codes a caller must act on and use default for the rest.
Tool gate: Spec linter that requires 4xx and 5xx responses with a schema on every operation, and an example on each.
Applies when: Callers of an HTTP or RPC API branch on the kind of error.
Rule. Give each error a stable machine-readable code that callers branch on, and never make them parse the message text.
Why. Message text gets reworded, translated and fixed. A client that matches on "already exists" breaks on the first edit. A published code is a contract.
FAIL
if "already exists" in response.json()["detail"]: # client
handle_conflict()
PASS
if response.json()["type"] == "https://api.example.com/problems/order-exists": # client
handle_conflict()
# the server keeps that type value for as long as the API version lives
Counterpoint. "The HTTP status is enough." It is too coarse: two different 409 conflicts need different handling. Add a code only where callers act differently; do not mint one for every branch.
Tool gate: Spec linter that requires an enumerated error type or reason per operation; a test that pins the codes.
Applies when: The service issues an API key or long-lived access token to a caller.
Rule. Issue each API key with the narrowest scopes and a bounded lifetime, and rotate or revoke it after any exposure.
Why. A key with account-wide scope turns one leaked string into the whole account, and a key with no end date is still valid years after the integration was forgotten. Scopes bound what a leak can do; an expiry or a rotation bounds how long it can do it. Rotating on a schedule, as Google and Stripe advise, also shows whether you can replace the key before an incident forces the question. Show the secret once, at creation, as AWS does.
FAIL
key = "sk_live_..." # full account scope, no expiry
db.insert(user_id, key) # plaintext, returned by every read
PASS
key = issue(user, scopes=["orders:read"], expires_in=90 * DAY)
return key # shown once
# rotate on a schedule; revoke at once on exposure
Counterpoint. "Short expiry and rotation break integrations that nobody watches." That is the cost, and it is smaller than a key that never dies. Prefer a temporary credential where the platform offers one, give each integration its own key so rotation is one at a time, and keep the overlap window long enough for a deploy. A shared key with no owner cannot be rotated safely, which is the real defect.
Applies when: A service defines JSON fields, query parameters, headers or path segments for an HTTP API.
Rule. Use one casing convention per location (fields, query parameters, headers, paths) across the whole API, and state it in the API guide.
Why. Mixed casing in one API makes callers look up each name. A client that guesses orderId after meeting order_id gets a silent null.
FAIL
GET /orders?pageSize=20 -> {"order_id": 1, "createdAt": "...", "Total-Cents": 500}
PASS
GET /orders?page_size=20 -> {"order_id": 1, "created_at": "...", "total_cents": 500}
# snake_case everywhere, as docs/api-guide.md says
Counterpoint. "Pick the right convention." The sources disagree: Zalando asks for snake_case, Microsoft for camelCase in fields. Either passes. Do not change an existing API's casing to match a guide; that is a breaking change.
Tool gate: Spec linter with a casing rule per location.
Applies when: A service returns errors from an HTTP API to other services or to clients.
Rule. Return errors as application/problem+json with a status that equals the HTTP status, and leave stack traces and internal detail out.
Why. Callers then parse every error one way. A body that differs per endpoint, or a status that disagrees with the real code, makes generic client and proxy handling unreliable. A stack trace in detail hands an attacker the implementation.
FAIL
HTTP/1.1 500
Content-Type: text/html
<pre>NullPointerException at OrderRepo.java:88 ...</pre>
PASS
HTTP/1.1 404
Content-Type: application/problem+json
{"type": "https://api.example.com/problems/order-not-found",
"title": "Order not found", "status": 404,
"detail": "No order with id 7731 for this account."}
Counterpoint. "We already have our own error envelope." That is fine inside one team's API, if it is one envelope and documented. The card asks for one shape everywhere; problem details is the one with a registered media type and existing client support. See sys-secrets-and-errors for what must stay out of any error.
Tool gate: Middleware that builds every error body from one problem-details type; a test that fails on an error response without the media type.
Applies when: A service answers 429 or 503 to HTTP callers.
Rule. Send Retry-After on every 429 and 503, and set it no earlier than the time capacity returns.
Why. Without it, every client invents its own backoff, and a hundred of them retry in the same second. The header lets the server pace its own recovery.
FAIL
HTTP/1.1 429 Too Many Requests
{"error": "slow down"}
PASS
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json
Counterpoint. "Clients should use exponential backoff anyway." They should, and svc-retry-policy says so. A server hint narrows the guess; it does not replace the client's cap. The IETF RateLimit header fields are still an Internet-Draft, so the card requires only Retry-After, which is in the HTTP standard. The quota itself is svc-rate-limit-quota.
Tool gate: Integration test that forces a 429 and a 503 and asserts a Retry-After header.
Applies when: A service returns HTTP responses that callers or intermediaries interpret by status code.
Rule. Return the most specific status code that fits: 201 with Location for a create, 405 with Allow for a wrong method, and a 4xx or 5xx for any failure.
Why. Clients, proxies and monitors act on the status line. A 200 with an error in the body hides failures from retries, caches and error-rate alerts. A create that returns 200 without Location forces the caller to guess the new URL.
FAIL
POST /orders -> 200 {"error": "out of stock"}
PATCH /orders (route is read-only) -> 404, no Allow header
PASS
POST /orders -> 201 Location: /orders/9031
POST /orders (out of stock) -> 409 application/problem+json
PATCH /orders (not supported) -> 405 Allow: GET, POST
Counterpoint. "A 200 with a status field is easier for our clients." It breaks every generic tool that reads the status line, including your own dashboards. Choosing between two fitting codes is a style call; the card fails only a code that says the wrong thing.
Tool gate: Integration tests asserting status and headers per operation; a spec linter for 201 and 405 responses.
Applies when: A service offers an HTTP API to callers you do not deploy together with it.
Rule. Write down one versioning scheme for the API, and ship no breaking change inside a version it has published.
Why. Callers upgrade on their own schedule. With no stated scheme, each breaking change is a surprise, and each team invents its own v2.
FAIL
GET /orders # renamed total -> amount_total
no version marker anywhere; README says "we try not to break things"
PASS
docs/api-versioning.md: dated api-version query parameter, previews last 6 months, 90 days' notice
GET /orders?api-version=2026-03-01 keeps returning "total"
the rename ships under api-version=2026-09-01
Counterpoint. "We will cross that bridge later." Later arrives with a customer on the old shape. The scheme can be minimal. A media type, a dated query parameter and a pinned header all work. Pick one and write it down. What counts as breaking is in chg-breaking-change-taxonomy.
Tool gate: Spec diff in CI that fails on a breaking change inside a published version.
Applies when: The service has a login, password-reset or one-time-code endpoint.
Rule. Limit failed attempts per account, and return the same message and status code whether or not the account exists.
Why. Unlimited guesses make passwords and 6-digit codes brute-forceable. Different errors for "no such user" and "wrong password" let an attacker list valid accounts first. Limit per account, since a per-IP limit is bypassed by rotating addresses. Response time leaks the same fact: if the code hashes the password only when the user exists, a fast reply means "no such user", so hash on every attempt. Registration needs its own design: give the same reply whether or not the address is taken, and email the owner of an existing account instead of creating a second one.
FAIL
if not user: return "No such user", 404
if not check(pw): return "Wrong password", 401 # no attempt limit
PASS
if too_many_failures(account_key): return "Try again later", 429 # checked before the password
ok = check(pw, user.pw_hash if user else DUMMY_HASH) # hash runs either way
if not (user and ok): record_failure(account_key); return "Invalid credentials", 401
Counterpoint. "A lockout lets attackers lock out real users." Use a rising delay, not a permanent lock, and keep the recovery flow reachable. Log every lockout so abuse shows up.
Tool gate: Rate-limit middleware on auth routes; test that known and unknown users get identical responses.
Applies when: The service has routes that need a signed-in user next to routes that are public.
Rule. Require authentication on every route by default, and mark each public route by name.
Why. When each handler opts in to authentication, the one that forgets is open to the internet, and nothing in the diff looks wrong. A global fallback flips the default: a new route is closed until someone marks it public, and that mark is a line a reviewer can question. Health checks and internal admin ports count as routes too.
FAIL
@app.get("/invoices/<id>") # no login check; anonymous callers get the invoice
def invoice(id): ...
PASS
PUBLIC = {"login", "health", "static"}
@app.before_request
def require_login():
if request.endpoint not in PUBLIC and not current_user.is_authenticated:
abort(401)
Counterpoint. "Our gateway already checks the token." Then the service trusts every caller that can reach it without the gateway: another pod, a debug port, a misrouted path. Keep the gateway check and the fallback in the service. The fallback costs one middleware.
Tool gate: A framework fallback that requires authentication (ASP.NET Core FallbackPolicy, Django LoginRequiredMiddleware, Spring Security anyRequest().authenticated()); a test that calls every route without credentials and expects 401 or a login redirect unless the route is on the public list.
Applies when: The service exposes routes or functions that different roles may call.
Rule. Route every request through one authorization layer that denies when no rule allows it.
Why. Per-handler checks drift: one admin route ships without its decorator and any user can call it. A central layer with deny as the fallback turns a forgotten check into a 403 instead of a hole.
FAIL
@app.route("/admin/export") # no role check; framework default is allow
def export(): ...
PASS
policy = {"export": {"admin"}} # endpoint -> roles, reviewed in one place
@app.before_request
def gate(): require_role(policy.get(request.endpoint)) # missing entry -> 403
Counterpoint. "A central policy is a bottleneck for feature teams." Keep the engine central and let each team own its rows. The cost of a one-line policy entry is small next to an unguarded admin route.
Tool gate: Router that denies routes with no policy; a test that lists every route and its required role.
Applies when: A request parameter or body sets how many records, bytes, items or operations the server handles.
Rule. Cap every client-controlled size and count on the server: body bytes, string length, array items, page size and batch size.
Why. A request for limit=10000000 or a batch of 50,000 operations lets one client exhaust memory or the bill for a paid backend. The cap has to sit in the server, since a UI limit is not one. svc-rate-limit-quota covers request rate; this card covers size.
FAIL
limit = int(request.args["limit"])
rows = db.fetch(limit) # no ceiling
items: list[Item] # schema with no maxItems
PASS
limit = max(1, min(int(request.args.get("limit", 50)), 100))
items: list[Item] = Field(max_length=100) # body capped at 1 MB by the proxy
Counterpoint. "Legit clients sometimes need big batches." Offer a documented bulk or export endpoint with its own quota. The default path stays capped.
Tool gate: Gateway request-size limit; schema maxLength/maxItems; test with a 10 MB body and limit=1000000.
Applies when: A shared cache (CDN, reverse proxy, gateway, or framework cache) stores responses the service generates.
Rule. Key every request input that can change the response, and strip any other request value before it reaches a cached body.
Why. A cache keeps one response per key. If the origin echoes a header, a cookie, or a GET body that the key ignores, the first request stores content that every later visitor receives. An attacker can then place content on your domain without touching the origin: a link or a form. A value that reaches the body belongs in the key, or it must not reach the body at all. Caching only static types by default narrows the surface, though CDN defaults differ.
FAIL
GET /home HTTP/1.1
X-Forwarded-Host: attacker.example
origin body: <a href="https://attacker.example/account">Account</a>
cache key: path only
The next visitor to /home receives the cached copy, with the attacker's host in the link.
PASS
edge: strip X-Forwarded-Host, X-Original-URL and X-Rewrite-URL before the origin
edge: return 400 to a GET with a body, or bypass the cache for it
origin: echo a header only when it is in Vary or the cache key
Cache-Control: public, max-age=60 Vary: Accept-Language
Counterpoint. "We leave that header out of the key to keep the hit rate up." It does raise the hit rate, by serving one requester's variant to everyone. Strip or rewrite the value when the response does not need it, and key it when it does.
See also.svc-api-cache-control owns Cache-Control and Vary; an input that changes the body must appear in the key or in Vary.
Tool gate: A test that sends an unkeyed header or a GET body and asserts the response body does not change.
Applies when: The service sends CORS headers and browsers send credentials or cookies to it, or the API is not meant to be public. A public bearer-key API with * and no credentials is outside it.
Rule. Allow cross-origin access only to a fixed list of named origins, and never echo the request Origin header back unchecked.
Why. Reflecting any Origin, or allowing * on an endpoint that returns private data, lets a hostile page read responses. CORS also does not stop CSRF, so it is no substitute for svc-csrf-state-changing. A subdomain regex that matches evil-example.com is the same bug, and so is allowing the null origin.
ALLOWED = {"https://app.example.com"}
if request.headers.get("Origin") in ALLOWED:
resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
resp.headers["Vary"] = "Origin" # caches keep one copy per origin
Counterpoint. "* is fine for a public API." It is, for endpoints that return only public data and take no credentials. Keep it off any endpoint behind a login.
Tool gate: Integration test that sends a hostile Origin and asserts it is not echoed.
Applies when: The service serves HTML pages to browsers.
Rule. Send a Content-Security-Policy whose script-src uses a per-response nonce or script hashes, with object-src 'none' and base-uri 'none'.
Why. When an XSS bug slips through, CSP decides whether the injected script runs. A host allowlist such as script-src 'self' cdn.example usually has a bypass, because some allowed host serves JSONP or an old library. A nonce or hash only lets through scripts the server marked. 'strict-dynamic' lets those scripts load their own children. Browsers that support nonces ignore an 'unsafe-inline' fallback placed next to one, so that fallback is safe. 'unsafe-inline' on its own turns the policy off for inline script.
Counterpoint. "A strict CSP breaks inline handlers and third-party tags." It does at first, so roll it out with Content-Security-Policy-Report-Only, move onclick= attributes into script files, and switch to enforce when the reports go quiet. A policy that never leaves report-only protects nothing, so give that step a date.
Tool gate: Google CSP Evaluator on the policy; a CI header check against a staging URL.
Applies when: The service authenticates browsers with cookies and changes state on requests.
Rule. Require a CSRF token (or an equivalent header check) on every cookie-authenticated request that changes state, and never change state on GET.
Why. The browser attaches cookies to a cross-site request, so a hostile page can post as the user. SameSite helps but Lax still sends cookies on cross-site top-level GET, so it supports the token and does not replace it. Naive double-submit cookies fail when a sibling subdomain can set cookies.
FAIL
@app.get("/account/delete") # state change on GET
@app.post("/transfer") # cookie auth, no token check
PASS
@app.post("/transfer")
@csrf_protect # token bound to the session
Set-Cookie: sid=...; SameSite=Lax; Secure; HttpOnly
Counterpoint. "Our API uses JSON, so browsers cannot forge it." A simple content type such as text/plain can carry a JSON body without a preflight. Reject it, or require a custom header such as X-CSRF-Token. Pure bearer-token APIs with no cookies are exempt.
Tool gate: Framework CSRF middleware enabled; test that a POST without the token returns 403.
Applies when: The code decompresses or decodes input whose size it does not control.
Rule. Cap the decompressed output of every decoder, and set the cap on the output rather than the compressed input.
Why. A small gzip can expand a thousand-fold: DEFLATE tops out near 1030:1, so a 1 MB gzip can inflate to about 1 GB, and nested archives or other codecs can go further. A body limit on the compressed bytes does not bound that expansion, so the decoder needs its own ceiling. Content-Length counts the compressed stream, not the inflated body, and Apache's inflated-body directive is unset by default. In Node, the convenience methods take maxOutputLength, whose default is buffer.kMaxLength, so pass a real number. The zip entry iterator is a separate case: zipEntry.contentIterator() has no default maxSize and is not covered by the zip-wide ceiling, so pass maxSize on that call. Python's tar filters address paths and links, and its docs say they do not prevent every unsafe archive, so size still needs its own bound. See also svc-bound-client-controlled-sizes for the request-body cap and svc-upload-validate-store for archives in uploads.
FAIL
const body = zlib.gunzipSync(req.body); // no maxOutputLength
parse(body);
Counterpoint. "The proxy already caps the request body." It caps the compressed bytes, which is the wrong number. A 1 MB gzip can still inflate to about 1 GB, and Content-Length says nothing about the decompressed size. Set the inflated cap at the decoder and keep the body cap. Where the platform offers a decompression limit, check that it is on: Apache's directive falls back to the post-deflation LimitRequestBody, and a value of 0 removes the inflated-body limit.
Tool gate: Apache DeflateInflateLimitRequestBody; ASP.NET Core MaxRequestBodySize; a test that inflates a 1 MB gzip to about 1 GB and asserts the decoder stops at the cap.
Applies when: The code consumes from a queue or stream.
Rule. Give every consumer queue a dead-letter queue and keep a way to replay it after the fix. On an ordered queue, key the group or session to the unit that must stay ordered, so a poison message holds only its own unit.
Why. A poison message otherwise loops forever, uses the consumers' capacity and hides the real backlog. On an ordered queue it also holds the messages behind it: in a FIFO queue no further messages from the same group are returned until the stuck message is deleted or becomes visible again, and an unacknowledged ordering key can delay the others. Give each independent stream its own group, session or queue, so one failure stays contained. Then cap the retries, so that a failing message moves to the dead-letter queue after a few attempts. Alarm on any message in the dead-letter queue, because each one is a bug to fix. Replay has a cost on these queues: a message put back after a dead-letter loses its place in the order, so replay only where order no longer matters, or reorder downstream.
FAIL
resource "aws_sqs_queue" "orders" {
name = "orders.fifo"
fifo_queue = true
# no redrive_policy: a message that always fails is retried forever
# and holds its whole message group
}
# every producer uses one group, so one stuck message blocks all orders
sqs.send_message(QueueUrl=url, MessageBody=body, MessageGroupId="orders")
# one group per order, so one stuck order does not block the others
sqs.send_message(QueueUrl=url, MessageBody=body, MessageGroupId=order_id)
Counterpoint. "A dead-letter queue already stops the poison message." It stops it after the retry cap. Until then the redelivery holds its group, and in a FIFO queue that means every message behind it. Grouping and the dead-letter queue do different jobs, so keep both. Choose the group key from the unit that must stay ordered. A single group restores the head-of-line block you were trying to remove. On a standard queue with no ordering, there is no group to key and the second sentence of the Rule does not apply.
Tool gate: A config check that every consumed queue has a dead-letter target (for SQS in Terraform, a redrive_policy with maxReceiveCount), and an alarm on dead-letter queue depth.
Applies when: The service reads configuration for authentication, an allow-list, a safety check, or a signing secret.
Rule. Ship every security control switched on, with an empty allow-list denying everything.
Why. A control that is off until configured is off in the deployment where nobody read the setup page. An empty allow-list that means "allow all" and a built-in changeme key both boot cleanly, so nothing alerts anyone while the service runs open or signs tokens anyone can forge. Refuse to start when a secret is missing or still a placeholder: failing at startup moves the discovery from an incident to the first deploy.
FAIL
import os
AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "false") == "true"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",") # "" means no filtering below
SECRET_KEY = os.environ.get("SECRET_KEY", "changeme")
def host_ok(host):
return not ALLOWED_HOSTS[0] or host in ALLOWED_HOSTS
PASS
import os
AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "true") == "true"
ALLOWED_HOSTS = [h for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h]
SECRET_KEY = os.environ["SECRET_KEY"] # KeyError stops the boot
if SECRET_KEY in {"", "changeme"}:
raise RuntimeError("SECRET_KEY is a placeholder")
def host_ok(host):
return host in ALLOWED_HOSTS # empty list denies all
Counterpoint. "Developers need to run it locally without setup." Give them a separate dev entry point or a dev-only flag that is named for what it does (INSECURE_DEV_MODE=1) and that production images never set. The default stays closed, and the open mode is a visible, greppable choice.
Tool gate: A startup check that raises on a missing or placeholder secret (Django refuses an unset SECRET_KEY; Pydantic Settings fields without a default); a test that boots the service with an empty environment and expects a failed start or denied requests.
Applies when: The service builds an absolute URL from the request, or reads Host or X-Forwarded-* to decide what to serve or where to send a user.
Rule. Build absolute URLs from configured values, answer only for a Host on an allowlist, and take X-Forwarded-* values only from a trusted proxy.
Why. A reset link built from the request Host carries whatever domain the attacker sent. The victim asks for a reset, the mail arrives with the attacker's host, and the token goes to the attacker when the link is opened. The same value drives routing, cache keys, and absolute redirects. A forwarded header is worse, because many stacks accept X-Forwarded-Host from any client and use it in place of Host. Two Host headers are a second route to the same disagreement: one component validates the first and another reads the last, so reject a request that carries more than one.
FAIL
reset = f"https://{request.headers['Host']}/reset?token={token}"
# no allowlist, and X-Forwarded-Host is honoured from any client
PASS
reset = f"{settings.PUBLIC_BASE_URL}/reset?token={token}"
ALLOWED_HOSTS = ["app.example.com"] # Django; any other Host: 400
TRUSTED_PROXIES = {"10.0.0.10"} # pseudo-config: your proxy middleware's setting
# (Django: USE_X_FORWARDED_HOST stays off unless a proxy sets it)
Counterpoint. "A reverse proxy sets Host, so the value can be trusted." It can only be trusted when the proxy overwrites any client-supplied value and the app accepts requests only from the proxy's address. An app that is reachable directly, or a proxy that passes the client header through, hands the value back to the attacker.
Tool gate: Framework host allowlist configured (ALLOWED_HOSTS, AllowedHosts, config.hosts, trustHosts), and a test that sends a hostile Host and expects 400.
Applies when: A non-idempotent operation (charge, create, send) can be retried by a client, a queue, or a saga.
Rule. Make every retryable write idempotent: the caller sends a unique key, and a retry with the same key never applies the write twice.
Why. A timeout does not say whether the first attempt ran. Retrying without a key charges twice or sends two emails. A common way to answer the retry is to store the first result and return it again.
FAIL
POST /payments {amount: 10} # retried on timeout
INSERT INTO payment(amount) VALUES (10)
PASS
POST /payments Idempotency-Key: k-123
INSERT ... ON CONFLICT (idem_key) DO UPDATE SET idem_key = EXCLUDED.idem_key RETURNING *
See also.sys-lock-fencing shares the goal but uses a different mechanism (stale lock holders).
Counterpoint. "Our retries are rare." Queues redeliver and sagas retry by design, so rare is not never. The cost is a unique key and a stored result. No standard fixes how long the server keeps a key. Pick a window and publish it.
Applies when: A handler takes a number, an id, a header or a body field from the caller and uses it in a query, a calculation or an encoder.
Rule. Check each caller-supplied value against its allowed domain (range, allowed set, an id that must exist, an encoding the code can handle) at the boundary, and answer a failed check with a 4xx, never a 5xx.
Why. A negative quota, a limit of zero, an id with no row, or a header with a non-ASCII character is the caller's error. If it reaches the database or an .encode("ascii"), the user gets a 500: the caller retries, the error-rate alert fires, and a negative quota may be stored and later allow unlimited use. A schema constraint or an explicit lookup costs one line per field. svc-bound-client-controlled-sizes caps size and count; this card covers every other value.
FAIL
@app.post("/quotas")
def set_quota(body: dict):
db.execute("UPDATE plans SET quota=%s WHERE id=%s", (body["quota"], body["plan_id"]))
# quota=-5 is stored; a plan_id with no row updates nothing and answers 200
return {"ok": True}
def sign(request):
return hmac_header(request.headers["X-Name"].encode("ascii")) # "Zoë" -> UnicodeEncodeError -> 500
PASS
class SetQuota(BaseModel):
quota: int = Field(ge=0, le=10_000)
plan_id: int
@app.post("/quotas")
def set_quota(body: SetQuota):
if not db.exists("SELECT 1 FROM plans WHERE id=%s", (body.plan_id,)):
raise HTTPException(404, "unknown plan_id")
db.execute("UPDATE plans SET quota=%s WHERE id=%s", (body.quota, body.plan_id))
def sign(request):
try:
name = request.headers["X-Name"].encode("ascii")
except UnicodeEncodeError:
raise HTTPException(400, "X-Name must be ASCII")
return hmac_header(name)
Counterpoint. "The database has a CHECK and a foreign key, so the data is safe." It is, but the constraint violation surfaces as a 500 unless the code maps it. Keep the constraint as the last line and validate first, so the caller gets a message that names the field. Catching the database error and mapping it to a 4xx also meets this card when a pre-check would race.
Tool gate: Schema or model constraints on every field (minimum, maximum, enum, format); a test that posts a negative number, an unknown id and a non-ASCII header and expects a 4xx.
Applies when: The service exposes an endpoint or RPC that returns a collection.
Rule. Paginate every list endpoint, with a default page size and a server-side maximum.
Why. A list that returns everything grows with the data until one request exhausts memory or the timeout. Adding pagination later breaks clients, so start with it. Without a maximum, a caller can ask for a page as big as the table.
FAIL
GET /orders -> every order the user ever placed
GET /orders?limit=1000000 (server honours it)
PASS
GET /orders?page_size=50&page_token=abc (default 25, values above 100 clamped to 100)
response: { "items": [...], "next_page_token": "def" }
Counterpoint. "This list is always small." Say so with a bounded type or a documented limit, since data tends to outgrow its first estimate. Cursors cost more than offsets to build, but they stay stable while rows change.
Tool gate: Contract test that calls the list with a huge page size and asserts the clamp or a 400.
Applies when: The service offers multi-factor authentication, or lets a signed-in user change a password, email address, or authentication factor.
Rule. Require a fresh authentication with an already-bound factor before sensitive actions and before adding, replacing or removing an MFA factor; an existing session alone is not enough.
Why. A session that passed a password check says nothing about who holds it now. Let that session enrol a new phone or replace an authenticator and an attacker with a stolen cookie keeps the account after the password is changed. The same gap covers password, email and admin-role changes. Step-up at the action asks for the factor only when it matters, and a short freshness window keeps the prompt rare. A session must not be treated as stronger than the authentication that created it. A user who lost the only factor needs a documented recovery path, such as single-use recovery codes or a temporary access pass. That path is part of the control, and it is judged as a separate recovery flow.
FAIL
@login_required
def add_phone():
user.phone = request.form["phone"] # any old session can replace the second factor
Counterpoint. "Prompting on every sensitive action annoys users, and a user who lost the only factor gets locked out." Use risk and a recent-auth window so the prompt appears only when the session is stale or the action is high risk, and keep the recovery path described above; without it, step-up becomes a denial of service against the account's owner.
Applies when: A client or server puts an API key, session token, bearer token, password, or personal data into a URL.
Rule. Send tokens, keys, passwords, and personal data in a header or the request body, never in the path or query string.
Why. A URL is copied to places nobody reviews: proxy and access logs, browser history, the Referer header sent to third-party scripts and images, and links pasted into chat. A token there stays readable long after the request ends. The Authorization header and a POST body skip all of those copies.
FAIL
GET /api/orders?api_key=sk_live_abc123
GET /reset?email=ana@example.com&token=9f2c
PASS
GET /api/orders
Authorization: Bearer sk_live_abc123
POST /reset
Content-Type: application/json
{"token": "9f2c"}
Counterpoint. "Email links have to carry the token in the URL." Make that token single-use and short-lived, have the landing page swap it for a session at once, and send Referrer-Policy: no-referrer on that page. A long-lived API key has no such excuse.
Applies when: The service acts as an OAuth 2.0 or OpenID Connect client and exchanges an authorization code for tokens.
Rule. Finish the authorization-code flow with PKCE and a challenge method that keeps the verifier out of the authorization request (S256), send the exact registered redirect_uri (a native app on the loopback interface may vary the port), and reject the response unless this transaction's state or OIDC nonce matches and the ID token's signature, iss, aud and exp check out.
Why. A code that is not bound to the browser in front of you can be injected into another session, and an ID token minted for a different client or issuer can be replayed at your login. PKCE makes an intercepted code useless without the verifier, and S256 is the method that does not reveal the verifier in the request. An exact redirect URI stops the code reaching an attacker's host. state or nonce ties the response to the login the user started. The ID-token checks reject a token that another app, another issuer or an expired clock made.
FAIL
code = request.args["code"]
token = exchange(code, redirect_uri=request.args["redirect_uri"])
session["user"] = jwt.decode(token["id_token"], options={"verify_signature": False})["sub"]
# no PKCE, no state check, no ID-token validation, and the redirect_uri is taken from the request
PASS
# the authorization request carried code_challenge=...&code_challenge_method=S256
if not secrets.compare_digest(request.args["state"], session.pop("oauth_state")):
raise PermissionDenied
token = exchange(request.args["code"], redirect_uri=REDIRECT_URI, # constant from config
code_verifier=session.pop("code_verifier"))
claims = verify_jwt(token["id_token"], jwks, iss=ISSUER, aud=CLIENT_ID,
nonce=session.pop("oauth_nonce"))
Counterpoint. "The OAuth client library already handles this." It handles the protocol, not your configuration. Some libraries decode the ID token unless you pass the issuer, audience and nonce, and the state or nonce store belongs to your session code. Test with a token minted for another client and a response with no state. The loopback exception is narrow: the port may differ, the host and path may not. The card is stricter than RFC 9700, which recommends PKCE for confidential clients and lets a confidential OIDC client use nonce instead; a reviewer may accept that alternative when the authorization server is known to bind the code another way.
Applies when: The service emails or texts a password-reset link or code.
Rule. Make the reset token single-use and short-lived, and on success revoke every session.
Why. A reset link is a bearer password that arrives in a mailbox. If it works twice, a forwarded mail, a mail scanner or a backup keeps the access alive. If it lives for days, an attacker has days to use it. And a reset that leaves the old sessions open changes the password without evicting the intruder who already had a session. Two further habits cost little: store only a hash of the token, so a database read or a backup hands over no working links, and revoke the other outstanding reset links on success.
FAIL
token = f"{user.id}-{int(time.time())}"
user.reset_token = token # predictable, stored in clear, usable again
send_email(link(token))
PASS
raw = secrets.token_urlsafe(32)
db.store(sha256(raw), user=user, expires=now + 15 * MINUTE)
# the raw value exists only in the mail
consume_once(raw)
revoke_sessions(user)
revoke_other_resets(user)
Counterpoint. "The token is random, so hashing it adds nothing." The mail, the link and every log between you and the user still carry the raw value; the hash protects the copy at rest, which is the copy an attacker reads. OWASP treats session invalidation as the user's choice in the normal flow and requires it after suspected compromise; this card asks for it every time because the reset may be the owner's answer to a stolen session. Pick the lifetime from a number the support flow can meet, not from the word "short", and send a notification after the reset so a change the owner did not make is visible.
Applies when: A shared API or queue serves several tenants or callers.
Rule. Enforce a per-tenant quota on shared APIs and queues.
Why. Without a quota, one noisy tenant consumes shared capacity for everyone. Key the count to the identity that owns the quota, such as the tenant, account or API key, and state the window and when capacity returns. Also add a per-tenant concurrency cap, and return 429 for over-quota but 503 for the service's own lack of capacity, so clients do not retry quota errors as capacity problems.
FAIL
every tenant shares one worker pool and one request budget
no per-tenant limit: one tenant's batch job starves the rest
PASS
limiter.check(tenant_id) -> 429 + Retry-After
per-tenant semaphore on workers
Counterpoint. "One global rate limit is simpler." A global limit protects the service, but one tenant can use all of it, and then every other tenant gets errors. A per-tenant limit costs one key per tenant in the limiter. Some older services return 503 for quota errors, so the status code is a convention. What matters is that a client can tell a quota error from a capacity error. Shuffle sharding (per-tenant worker subsets) isolates tenants further, and this card does not require it.
Applies when: The service parses HTTP requests itself, or receives them through a proxy, load balancer, or CDN.
Rule. Reject a request that carries both Transfer-Encoding and Content-Length, or remove one field at the first hop, and parse header framing strictly rather than with a leniency flag.
Why. Two hops can disagree about where a request ends. The front end reads one body length and the back end reads another, so the leftover bytes become the start of the next request on the same connection. That request skips the front end's checks: it can reach an admin route, poison a cache, or splice one user's response into another user's connection. The ambiguity comes from a second framing field, a malformed header name, or a parser that accepts what the specification rejects. A front end that downgrades HTTP/2 to HTTP/1.1 reopens the gap, so validate the rewritten request as well.
FAIL
POST /login HTTP/1.1
Host: app.example
Content-Length: 15
Transfer-Encoding: chunked
0
GET /admin
The proxy forwards this over a reused upstream connection. The front end reads fifteen bytes as the body. The back end finds the end of the chunked body and keeps GET /admin for the next request on the connection, so it reaches a route the front end never checked.
PASS
# front end
if both framing fields are present:
return 400 and close the connection # or drop Content-Length, forward, then close
# parser
insecureHTTPParser: false # Node
allow_chunked_length: false # Envoy
whitespace before a header colon: 400
Counterpoint. "We speak HTTP/2 end to end, so there is nothing to smuggle." HTTP/2 has one framing mechanism, but many deployments still downgrade at the last hop, and compatibility flags keep the old leniency in parsers. Check both ends of every connection and the rewrite between them, not the version the client negotiated.
Tool gate: A test that sends one request with both Content-Length and Transfer-Encoding and expects 400 or a closed connection.
Applies when: A business operation spans services, each with its own database, coordinated as a saga.
Rule. Make steps after the pivot idempotent and retryable, and make every compensation idempotent with a failure path.
Why. After the pivot there is no undo, only retry until done. A compensation can itself fail, and unhandled it leaves the system inconsistent with nobody told.
step 4: charge_card(order, idempotency_key=order.id) # safe to retry
compensate: release_stock(order) retried; on exhaustion -> alert + manual queue
Counterpoint. "Use a distributed transaction." Often unavailable across services. A saga has no isolation, so also guard against reading half-done data (semantic locks, re-reading values before acting). svc-idempotency-key covers keys, and svc-dlq covers failed messages.
Applies when: The service is a SAML 2.0 service provider and accepts a response or assertion at an assertion consumer service endpoint.
Rule. Verify the XML signature over the assertion you read, with the IdP's configured key rather than a key carried in the response, then require the assertion's Audience and its bearer SubjectConfirmationDataRecipient to name this SP, the response Destination to be this endpoint, InResponseTo to match your request (absent for an IdP-initiated response), NotOnOrAfter to be current, and the assertion ID to be unseen. These are one step, verify the assertion, and each check is cited.
Why. A valid signature elsewhere in the document proves nothing about the <NameID> your code just read. That gap is the signature-wrapping attack, and it lets an attacker choose which identity your service trusts. A lookup such as .//NameID is how the signed element and the read element get separated. Pinning the key to the IdP stops the document choosing its own verification key. The audience and destination checks stop an assertion minted for another SP from being posted here. InResponseTo catches a response injected into a browser that never started the request, and the time window plus the used-ID set stop a captured assertion from being replayed.
FAIL
doc = etree.fromstring(base64.b64decode(form["SAMLResponse"]))
name_id = doc.find(".//NameID").text
login(name_id) # no signature check, no audience, no replay set
PASS
signed = verify_signature(response, key=IDP_CERT) # returns the signed element
assertion = signed.assertion
if assertion.audience != SP_ENTITY_ID or response.destination != ACS_URL:
raise InvalidAssertion
if assertion.subject_confirmation.recipient != ACS_URL:
raise InvalidAssertion
if assertion.subject_confirmation.in_response_to != request_id:
raise InvalidAssertion
if assertion.not_on_or_after <= now() or used_ids.seen(assertion.id, until=assertion.not_on_or_after):
raise InvalidAssertion
Counterpoint. "The library validates the signature, so the assertion is trustworthy." The signature vouches for the bytes it covers, not for the element your handler reads. A strict mode that validates the response but leaves an unsigned assertion in the tree still passes a wrapped NameID. Require the assertion to be inside the signed content, take the key from IdP metadata, and record the ID of the assertion you accepted, not the ID of its wrapper.
Applies when: The service sets a session cookie or session ID after login.
Rule. Issue a new session ID at login, and set Secure, HttpOnly and SameSite on the session cookie.
Why. A session ID that survives login can be planted by an attacker beforehand (fixation). A cookie without HttpOnly is readable by any injected script, and one without Secure travels over plain HTTP. Also end the session on the server at logout, because clearing only the browser cookie leaves a stolen copy valid, and enforce idle and absolute timeouts on the server. Scope the cookie to one host. With Domain omitted it is returned only to the host that set it, while a Domain set to the registrable domain sends it to every subdomain. Omitting Domain does not stop planting: a sibling subdomain that serves user content can still set a parent-domain cookie with the same name for the parent to accept. The __Host- prefix is what stops that, because the browser refuses a __Host- cookie that carries Domain or a path other than /. Use it on the session cookie.
FAIL
Set-Cookie: sid=abc123
login(): keep the pre-login session id
PASS
Set-Cookie: __Host-sid=<new random>; Secure; HttpOnly; SameSite=Lax; Path=/ # the __Host- name is optional hardening, not a Rule clause
login(): session.regenerate()
Counterpoint. "The framework already does this." Some do: Spring Security changes the session ID at login by default, and Rails gives you reset_session. Hand-written login code and custom session stores often skip it, and cookie flags depend on configuration. Check the login handler and the real Set-Cookie header, not the framework's reputation.
Tool gate: Header test on the login response; session fixation test in the end-to-end suite.
Applies when: The code builds a SQL query that includes request data or other untrusted input.
Rule. Pass untrusted data to queries as bound parameters, never by string concatenation or formatting.
Why. Concatenated input becomes SQL, so a value of 1 OR 1=1 reads every row and a quote ends the string early. A bound parameter travels apart from the query text, and the database never parses it as SQL.
FAIL
db.execute("SELECT * FROM users WHERE id = " + req.id)
db.execute(f"SELECT * FROM users WHERE name = '{name}'")
PASS
db.execute("SELECT * FROM users WHERE id = ?", (req.id,)) # the placeholder style depends on the driver
db.execute("SELECT * FROM users WHERE name = ?", (name,))
Counterpoint. "An ORM makes this moot." Raw fragments still bypass it, and so do table, column and ORDER BY names, which cannot be bound. Map those to a fixed allowlist in code.
Applies when: A shared log, metric or audit store holds records from more than one tenant.
Rule. Put the verified tenant id on every tenant-scoped log, metric and audit record.
Why. A shared store returns another tenant's activity to any query that does not filter. The id has to be written from the verified tenant context, not from a request header, and it has to be there when a batch job or a queue worker writes the record, since no request middleware runs in those paths. A centralized audit store is acceptable only when read access enforces tenant scope. Metric events need the field too, or a per-tenant dashboard shows another tenant's numbers. Platform events that belong to no tenant, such as a node restart, carry no tenant id and are outside the Rule.
Counterpoint. "The pipeline adds the tenant from the request context." That works only for code that runs inside a request. Workers, schedulers and sidecars write records too, and a query cannot recover a field that was never written. If a shared layer injects the field, verify it with a two-tenant query, because a layer that silently skips one producer looks the same as a correct one until the query runs. Splitting logs into a store per tenant is stronger still where the operational cost is justified.
Tool gate: A test that writes an event for each of two tenants, then queries one tenant and asserts the other is absent.
Applies when: The service or its load balancer terminates TLS.
Rule. Accept only TLS 1.2 and TLS 1.3 on servers.
Why. TLS 1.0 and 1.1 lack modern cipher suites and are deprecated by RFC 8996, and SSL 3.0 is broken outright. A server that still offers them lets an attacker on the path force a downgrade, and it fails compliance scans. Library defaults have moved to TLS 1.2, but an explicit lower setting, an old base image, or a load-balancer policy from years ago keeps the old versions alive. With TLS 1.2, prefer AEAD suites with forward secrecy.
Counterpoint. "Some of our users have old clients." Measure first: log the negotiated version, and if old clients are a real share, serve them from a separate endpoint with an end date instead of weakening every endpoint.
Tool gate: testssl.sh or a similar scan against the public endpoint in CI; the load balancer's named security policy checked in config review.
Rule. Allowlist the file type, cap the size, and store each upload under a generated name where the server will not execute it.
Why. The client-supplied name and Content-Type are attacker text. A user-chosen name allows path tricks, and an upload served from the web root with its original extension can run as code. Count the decompressed size of archives. Storing outside the web root and serving files through a handler, or from a separate domain, keeps a file that slips past the type check from running. fn-confine-file-path covers the path check.
FAIL
path = f"/var/www/uploads/{file.filename}"
file.save(path) # any type, any size, public URL
PASS
if sniff_type(file.stream) not in {"image/png", "image/jpeg"} or size > 5_000_000: abort(400) # type from the content, not the name
file.save(STORE / f"{uuid4()}.bin") # outside web root; served by /files/<id>
Counterpoint. "The extension check is enough." The extension is attacker text, and double extensions or parser quirks get past a check on it. The generated name and a storage place that never executes files are what make a file that slips through harmless.
Tool gate: Upload tests with a renamed .php, an oversized file and a zip bomb.
Applies when: The code accepts signed webhooks or signed messages from another system.
Rule. Reject an inbound signed message whose creation time is outside a short tolerance, and sign that time with the body so it cannot be swapped.
Why. A valid signature proves who sent the message, not when. A captured request keeps its signature and can be sent again. Put the timestamp inside the signed content, as Slack, Svix and Stripe do. A timestamp in a separate header that the signature does not cover can be replaced by the attacker. Compare the timestamp with local time before doing any work. Retries are normal, so also store the message id and skip an id already processed. Deduplication stops a replay inside the window. The window stops one outside it and bounds how long the id store must keep entries.
FAIL
if not verify_hmac(body, headers["X-Signature"], secret):
return 401
apply_event(body) # a captured request replays for as long as the secret lives
PASS
ts = headers["X-Timestamp"]
if abs(time.time() - int(ts)) > 300:
return 401
if not verify_hmac(f"{ts}.".encode() + body, headers["X-Signature"], secret):
return 401
apply_event(body)
Counterpoint. "A tight window drops delayed deliveries and retries." It does. RFC 9421 lets an application enforce a maximum signature age and leaves the number to it, and five minutes is the default of Slack and Stripe, not a standard. Pick a tolerance from the provider's retry schedule and your clock skew, log every reject with the offset, and keep deduplication by event id so a replay inside the window still does nothing. A signature with no timestamp at all cannot be bounded this way. The receiving endpoint has to rely on deduplication and on the sender's short-lived credential.
Tool gate: A test that replays a captured request with the timestamp moved outside the tolerance and asserts a 4xx, and one that changes only the timestamp header and asserts a 4xx.
A draft card is not vetted. Report a FAIL of one under Draft findings, at should-fix at most, never in the ledger or the verdict. How a draft becomes stable
svc-api-safe-idempotent-methods
should-fixdraftcompatibility
Applies when: A service exposes HTTP endpoints, or a handler changes which method it accepts.
Rule. Keep GET, HEAD and OPTIONS free of side effects, and make PUT and DELETE give the same end state when repeated.
Why. Clients, caches, prefetchers and crawlers repeat safe methods freely, and clients and proxies retry PUT and DELETE automatically after a connection failure. A GET that deletes or counts, or a PUT that appends, turns a retry into data loss or duplicates.
FAIL
GET /reports/7/archive -> moves report 7 to the archive
PUT /carts/9/items -> appends the item again on each call
PASS
POST /reports/7/archive -> state change under an unsafe method
PUT /carts/9/items/42 {qty: 2} -> sets the item; repeating it changes nothing
Counterpoint. "We need a link a user can click to confirm an email." Make the click a GET that shows a page, and have the page submit a POST. Logs or counters written by a GET are fine; the rule covers changes to resource state that a caller can see. Retries of POST fall under svc-idempotency-key.
Why it is a draft: it overlaps a stable card. The GET half is already in stable svc-csrf-state-changing; PUT and DELETE have IETF only How a draft becomes stable
Tool gate: API linter, and an integration test that calls each GET twice and each PUT or DELETE twice and compares state.
Applies when: A service publishes an OpenAPI (Swagger) description that callers, SDK generators or gateways read.
Rule. Keep the OpenAPI document as the source of truth, and generate it from the code or test the running service against it in CI.
Why. A hand-edited spec drifts from the handlers. Callers and generated SDKs then code against fields and codes the service does not return.
FAIL
openapi.yaml (hand-written, last edited 14 months ago)
POST /orders returns {id, status, total_cents}; spec lists {id, status, total}
no CI step reads openapi.yaml
PASS
CI: schemathesis run openapi.yaml --url $STAGING # fails on any response the spec does not describe
or: the framework emits the spec from the handler types and the file is committed
Counterpoint. "The spec is documentation, a stale one does little harm." Generated SDKs, gateways and mock servers read it as fact, and a wrong spec fails there first. If the team does not publish a spec, mark the card N/A.
Why it is a draft: the card contradicts itself. The Rule makes the spec the source of truth, then allows generating it from code; pick one How a draft becomes stable
Tool gate: Contract test or schema validation in CI that runs requests against the service and checks responses against the spec, or a spec generated from the code.
Applies when: A service calls another service over HTTP while handling a request.
Rule. Forward traceparent and tracestate on every outgoing call, keep the incoming trace id, and set a new parent id for each hop.
Why. A hop that drops the header splits one request into two traces. When something is slow or failing, the trace ends at the service that forgot, and the next hop cannot be found.
FAIL
resp = http.post(PAYMENTS_URL, json=body) # no trace headers; a new trace starts in payments
Counterpoint. "A mesh or agent handles it." Then show it propagates through your queue consumers and thread pools too; a sidecar sees only the network edge. Never put personal data in either header, since they travel to third parties. The id in log lines is svc-request-id-logging.
Why it is a draft: too few organisations back a clause of the Rule. W3C is the only backer; needs two more orgs that forward trace headers on every call How a draft becomes stable
Tool gate: Instrumentation library or HTTP-client middleware that injects traceparent; an integration test that checks the outgoing header.
Applies when: One API or store serves requests whose cost differs by orders of magnitude, such as GraphQL queries, scans next to point reads, or bulk endpoints.
Rule. When request costs differ by orders of magnitude, charge each request its estimated cost against the quota instead of counting it as one.
Why. A request count treats a one-row lookup and a 100,000-row scan the same, so a client within its quota can still overload the backend. Shopify computes a GraphQL query's cost by static analysis before it runs: one point per object, nothing for scalars, and a budget of 50 points per second up to 1,000. GitHub's GraphQL API also limits points, not calls. Airbnb's key-value store charges request units from the bytes read and the latency of each operation.
FAIL
def handle(tenant, query):
if not limiter.take(tenant, 1): # a full scan costs the same as a point read
raise TooManyRequests()
return run(query)
PASS
def handle(tenant, query):
cost = estimate_cost(query) # static: one point per object requested
if not limiter.take(tenant, cost):
raise TooManyRequests(retry_after=limiter.wait_for(tenant, cost))
return run(query)
Counterpoint. "A cost model is guesswork." Uber tried a bytes-based quota for its storage and dropped it. A full table scan that returned one row looked cheap, and the shared counter in Redis added a network hop and a failure point. Base the cost on something you can measure, such as objects requested or bytes read, check it against real load, and keep a concurrency limit as a backstop. Where every request costs about the same, a request count is fine and the card is N/A. Which key carries the quota is svc-rate-limit-quota. This card covers the unit it counts in.
Why it is a draft: a source is a plan, a definition, or a summary rather than a stated practice. Shopify, GitHub and Airbnb describe cost-based limits; none recommends it, and Uber dropped a bytes-based quota How a draft becomes stable
Applies when: A service handles requests and writes logs while doing so.
Rule. Put the request or trace id on every log line written during a request.
Why. Concurrent requests interleave in one log. Without a shared id, you cannot pull one request's lines together or match them with other services' logs and the trace.
FAIL
log.info("payment failed") # which request?
PASS
log.info("payment failed", extra={"trace_id": ctx.trace_id}) # id set once by middleware
See also.svc-api-trace-context covers the traceparent header that carries the id between services.
Counterpoint. "Ids are high-cardinality and will blow up our metrics." Keep them out of metric labels (prod-wide-events-bounded-labels). Logs and events are where per-request ids belong.
Why it is a draft: a source is a plan, a definition, or a summary rather than a stated practice. The W3C spec does not mention logs; only Honeycomb backs putting the id on every log line How a draft becomes stable
Tool gate: Logging middleware or formatter that injects the id; test that fails on a log line without it.
Rule. Log every failed authentication with the account name and a timestamp.
Why. Without these events, password guessing in progress is invisible and an incident cannot be reconstructed. Log authorization denials and validation failures the same way, and add a correlation ID so one request can be followed across services. Never log passwords, tokens or session IDs, because the log then becomes a second leak. Untrusted values with CR or LF can forge log lines, so use structured output.
Counterpoint. "Logging every failed login is noisy." Log them as one event type, count them, and alert on the rate, not on each line. Without the events, nobody sees the attack.
Why it is a draft: the maintainer keeps it as a draft. Kept draft by Pablo's call How a draft becomes stable
Tool gate: Test that a failed login produces one structured log line with the account and a timestamp.
Applies when: The service serves HTML pages or sets cookies for browsers over HTTPS.
Rule. Send Strict-Transport-Security on every HTTPS response.
Why. Without HSTS, the first plain-HTTP request can be downgraded by anyone on the path, and a user can click through a certificate warning. With it, the browser rewrites http to https and refuses the click-through. Two more one-line headers belong next to it: X-Content-Type-Options: nosniff, so a browser does not run an upload as script, and CSP frame-ancestors (or X-Frame-Options: DENY), so a hostile page cannot frame yours for clickjacking.
FAIL
HTTP/1.1 200 OK
Content-Type: text/html
(no Strict-Transport-Security)
Counterpoint. "HSTS can lock users out if the certificate lapses." True, so ramp max-age up and check the preload rules before you submit the domain. A lockout from an expired certificate is an outage you fix, while a downgrade is a breach.
Why it is a draft: the maintainer keeps it as a draft. Kept draft by Pablo's call How a draft becomes stable
Tool gate: Header check in CI against a staging URL; framework middleware that sets defaults.