Playbook library
API security baseline
Generated endpoint code is confident and structurally correct, and that is exactly the problem: it looks finished while trusting its input, returning whole database rows, and authorizing on the route instead of the record. These rules target the failures that survive a linter and a quick human read.
- Written for
- Teams shipping HTTP APIs — Node, Python, Go, any framework.
- Not covered
- Nothing here replaces a dependency scanner, a secrets scanner, or a pentest. These rules judge the code in the diff — they cannot see your gateway config, your IAM policies, or a vulnerability in a package you pulled in.
10 rules6 blocking4 warning
01 Parse request input before using it
errorEvery handler that reads a body, query string, path param or header must parse it through a schema (zod, pydantic, JSON Schema…) before the value reaches a service or a query. Passing an unvalidated object straight through is rejected, including when a type annotation claims its shape. A type annotation is a compile-time claim about runtime data you do not control — mass assignment and injection both enter through the gap between the two.
Blocks: an unvalidated boundary is exploitable the day it ships, and it is trivially visible in the diff.
Scopesrc/routes/**src/**/*.controller.*apps/api/**
Tagssecurityvalidationapi
02 Authorize on the record, not the route
errorA handler that loads a resource by id must also assert that the resource belongs to the caller — owner, tenant, or an explicit permission check — in the same query or immediately after it. Authenticated is not authorized. Route-level middleware cannot see which row you fetched, so IDOR is the flaw that survives every layer of generic protection.
Blocks: this is the most common exploitable bug in AI-written CRUD code.
Scopesrc/routes/**src/**/*.controller.*apps/api/**
Tagssecurityauthorization
03 Never serialize a raw database row
errorResponses are built from an explicit DTO or a named column selection. Returning an entity directly, or spreading one into the response object, is rejected. A `select *` plus a spread leaks whatever column someone adds next — password hashes, internal flags, soft-delete markers — and nothing in the diff that adds the column will look wrong.
Blocks: the leak is silent and retroactive — it appears without anyone editing this file.
Scopesrc/routes/**src/**/*.controller.*apps/api/**
Tagssecurityapiprivacy
04 Verify webhook signatures against the raw body
errorA webhook handler verifies the provider signature over the unparsed request body before any parsing, database write, or side effect. Body-parsing middleware must be disabled on those routes. An unverified webhook endpoint is an unauthenticated write endpoint that anyone who reads your docs can call.
Blocks: an unsigned webhook route is a direct path into your write model.
Scope**/webhooks/****/*webhook*
Tagssecurityintegrations
05 Secrets come from config, with no fallback
errorCredentials, API keys and signing secrets are read from the typed config module. A literal secret, a value committed in a fixture, or a `process.env.X || "dev-secret"` style fallback is rejected. A fallback secret becomes the production secret on the first deploy where the environment variable is missing, and nothing fails loudly enough for anyone to notice.
Blocks: a committed or defaulted secret is a disclosure the moment it merges.
Scopewhole repository
Tagssecurityconfig
06 CORS origins are an explicit allowlist
errorThe allowed origins are an explicit list from config. Reflecting `req.headers.origin`, or combining a wildcard with credentials, is rejected. Reflecting the caller's origin with credentials enabled removes the same-origin policy for every authenticated route at once.
Blocks: it silently disables the browser protection the rest of the auth design assumes.
Scopesrc/**apps/api/**
Tagssecurityapi
07 Compare secrets in constant time
warningAPI keys, session tokens and signatures are compared with a constant-time function (`crypto.timingSafeEqual`, `hmac.compare_digest`), never `===` or `==`. String comparison returns early on the first differing byte, so the response time leaks how much of the secret the attacker already guessed — and the fix is one function call, so there is no reason to leave the leak there.
Warns rather than blocks: the attack is real but slow and noisy, and the fix is a one-line follow-up.
Scopesrc/**/auth/**src/**/*token*src/**/*signature*
Tagssecurityauth
08 Unauthenticated routes declare a rate limit
warningAny route reachable without a session — login, signup, password reset, public search, magic links — must attach a rate limiter or reference the gateway policy that covers it in a comment. Without one, credential stuffing and reset-email flooding cost the attacker nothing.
Warns: the limiter often lives at the gateway rather than in the diff, so this asks the reviewer to confirm it exists somewhere rather than failing the build.
Scopesrc/routes/**apps/api/**
Tagssecurityabuse
09 Errors return a code, not an internal
warningClient-facing errors carry a stable machine-readable code and a generic message. The stack trace, the failing query and the upstream response body go to the log only. Echoing `err.message` to the client is how connection strings, SQL fragments and internal hostnames end up in a bug report screenshot.
Warns: it is an information leak, not a direct compromise — but it feeds every other attack.
Scopesrc/routes/**src/**/*.controller.*apps/api/**
Tagssecurityerrorsapi
10 Logs carry identifiers, not payloads
warningLog an event name and the ids involved. Never log a request body, an authorization header, a token, or a full user record — including inside a catch block. Logs are shipped to third parties and retained far longer than the data policy that covers the database.
Warns: it is a compliance and retention problem rather than an immediate breach.
Scopewhole repository
Tagssecurityprivacylogging
Before you import all of it
Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.
Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.
Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.
The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.