Production Security Checklist
The whole course as a list you can run down before shipping, ordered by what actually causes breaches.
Ordered by real-world impact rather than by category, so the first section is the one that matters most.
1. Authorization — where breaches come from
- Ownership checked inside the query, on every route taking an id.
- Tenant taken from the verified credential, never from the path or a header.
- Foreign objects return
404, not403. - Nested routes verify the parent, then scope the child to it.
- Bulk endpoints scope the whole set and refuse partial matches.
- Role and scope both checked where both apply.
- Admin routes protected at the router, not per handler.
- Export, search, webhook and GraphQL paths have the same checks as REST.
- Row-level security in the database as a backstop.
2. Input and output
- Schema validation on every endpoint, rejecting unknown fields.
- Writable fields are an allowlist; responses are shaped explicitly.
- Parameterised queries everywhere; sortable columns from an allowlist.
- Body size, array length, string length and JSON depth all bounded.
3. Authentication
- Argon2 or bcrypt; identical responses and timing for unknown user and wrong password.
- Rate limits per IP and per account; MFA for admins.
- Session id regenerated at login and after MFA; idle and absolute timeouts.
- Tokens short-lived, refresh rotating with reuse detection; revocation delay stated as a number.
4. Resource limits
- Page size capped; cursor pagination for deep lists.
- Timeouts ordered: database < upstream < request < load balancer.
- Quotas on anything that costs money; spend alerting.
5. Configuration and transport
- TLS 1.2+, HSTS, automated certificates with 30-day alerting.
- CORS: exact origins, no wildcard with credentials,
Vary: Origin. - Debug surfaces off; the app refuses to boot if they are on.
- Secrets from a store, validated at boot; nothing in git or an image layer.
6. Detection
- Auth events, denials and privileged actions audited, append-only.
- Alerts on denial bursts, volume anomalies and canaries.
- An inventory generated in CI, with an owner per route.
Example
# The ten-minute pre-ship test. Any surprise is a blocker.
t 404 "another user's object" "$API/api/invoices/$BOB_INVOICE" -H "$ALICE"
t 404 "another tenant's object" "$API/api/invoices/$CAROL_INVOICE" -H "$ALICE"
t 403 "admin route as a user" "$API/api/admin/users" -H "$ALICE"
t 401 "revoked credential" "$API/api/invoices" -H "$REVOKED"
t 400 "SSRF to metadata" -X POST "$API/api/import" -H "$ALICE" \
-d '{"url":"http://169.254.169.254/"}'
# limit is capped
[ "$(curl -s "$API/api/invoices?limit=1000000" -H "$ALICE" | jq '.data|length')" -le 100 ]
# role is not writable
curl -s -X PATCH "$API/api/users/me" -H "$ALICE" -d '{"role":"admin"}' >/dev/null
[ "$(curl -s "$API/api/users/me" -H "$ALICE" | jq -r .role)" = "user" ]
# brute force is throttled
for i in $(seq 1 30); do login_wrong; done | grep -q 429When to use it
- A release checklist catches a missing ownership check introduced by a refactor, minutes before deployment.
- An auditor is given the checklist with evidence per line, turning a week-long review into a day.
- A new engineer uses the checklist to review their first endpoint and finds two issues without prior security experience.
More examples
The checklist as CI assertions
Passing the route description into the expect message is a small detail that turns a failing suite into an immediately actionable list of endpoints.
// Every line of a checklist that can be a test should be one. A document is
// followed when someone remembers; a test is followed always.
describe('production readiness', () => {
// ── 1. Authorization ───────────────────────────────────────────────
it('every id-taking route rejects a foreign object with 404', async () => {
for (const route of routes.filter((r) => r.takesId && !r.adminOnly)) {
const res = await call(route, { as: alice, targeting: bobsObject(route) });
expect(res.status, `${route.method} ${route.path}`).toBe(404);
}
});
it('every id-taking route rejects another tenant with 404', async () => {
for (const route of routes.filter((r) => r.takesId)) {
const res = await call(route, { as: alice, targeting: globexObject(route) });
expect(res.status, `${route.method} ${route.path}`).toBe(404);
}
});
it('every admin route rejects a normal user', async () => {
for (const route of routes.filter((r) => r.adminOnly)) {
expect((await call(route, { as: alice })).status).toBe(403);
}
});
// ── 2. Input and output ────────────────────────────────────────────
it('no route accepts a privileged field', async () => {
for (const route of routes.filter((r) => r.acceptsBody)) {
const before = await snapshotUser(alice.id);
await call(route, { as: alice, body: { role: 'admin', tenantId: 'x' } });
expect(await snapshotUser(alice.id)).toEqual(before);
}
});
it('no response contains a sensitive field', async () => {
for (const route of routes.filter((r) => r.method === 'GET')) {
const res = await call(route, { as: alice });
if (res.status === 200) expect(findSensitiveFields(res.body)).toEqual([]);
}
});
// ── 3. Authentication ──────────────────────────────────────────────
it('does not reveal whether an account exists', async () => {
const a = await login('[email protected]', 'wrong');
const b = await login('[email protected]', 'wrong');
expect(a.body).toEqual(b.body);
expect(Math.abs(a.durationMs - b.durationMs)).toBeLessThan(50);
});
it('throttles repeated failures', async () => {
const codes = [];
for (let i = 0; i < 30; i++) codes.push((await login('[email protected]', 'x')).status);
expect(codes).toContain(429);
});
// ── 4. Resource limits ─────────────────────────────────────────────
it('caps page size on every collection', async () => {
for (const route of routes.filter((r) => r.returnsCollection)) {
const res = await call(route, { as: alice, query: { limit: 1_000_000 } });
const items = res.body.data ?? res.body;
expect(Array.isArray(items) ? items.length : 0).toBeLessThanOrEqual(100);
}
});
// ── 5. Configuration ───────────────────────────────────────────────
it('sets the required security headers', async () => {
const res = await call(routes[0], { as: alice });
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['strict-transport-security']).toBeDefined();
expect(res.headers['x-powered-by']).toBeUndefined();
});
it('does not send CORS headers to an untrusted origin', async () => {
const res = await request(app).get('/api/health').set('Origin', 'https://evil.com');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('never returns a stack trace', async () => {
const res = await request(app).get('/api/trigger-error');
expect(JSON.stringify(res.body)).not.toMatch(/at \/|\.js:\d+|node_modules/);
});
// ── 6. Inventory ───────────────────────────────────────────────────
it('every route has an owner and an authentication decision', () => {
for (const route of routes) {
const entry = owners[`${route.method} ${route.path}`];
expect(entry, `${route.method} ${route.path}`).toBeDefined();
expect(entry.owner).toBeTruthy();
expect(route.auth || entry.publicIntentionally).toBe(true);
}
});
});Ordered by what actually causes breaches
Ordering by observed impact rather than by category is the point — most checklists put TLS first, and TLS is almost never why an API is breached.
# Checklists are usually ordered by category, which buries the important part.
# This is ordered by observed impact.
## TIER 1 — causes most real API breaches. Fix before anything else.
[ ] Ownership check INSIDE the query, on every route taking an id
[ ] Tenant from the credential, never from the path
[ ] Nested routes verify the parent, then scope the child
[ ] Bulk endpoints scope the whole set
[ ] Export / search / webhook / GraphQL have the SAME checks as REST
[ ] Admin routes protected at the router
[ ] Foreign object → 404, never 403
## TIER 2 — high impact, commonly missing
[ ] Schema validation with unknown fields REJECTED
[ ] Writable fields are an allowlist
[ ] Responses shaped explicitly (never res.json(model))
[ ] Parameterised queries; sort columns from an allowlist
[ ] Page size capped; body size capped
[ ] Rate limits per IP AND per account
[ ] Argon2/bcrypt; identical response and timing for unknown user
## TIER 3 — important, and usually already right
[ ] TLS 1.2+, HSTS, automated certificates
[ ] CORS exact origins; no wildcard with credentials; Vary: Origin
[ ] Security headers (nosniff, Referrer-Policy, no X-Powered-By)
[ ] Debug surfaces off; the app refuses to boot if they are on
[ ] Secrets from a store; nothing in git or an image layer
[ ] Generic errors with a request id; no stack traces
## TIER 4 — the difference between a bad day and a bad quarter
[ ] Auth events, denials and privileged actions audited, append-only
[ ] Alerts on denial bursts, volume anomalies, canaries
[ ] Route inventory generated in CI, owner per route
[ ] Incident runbook with COMMANDS, tested quarterly
[ ] Retention long enough to investigate something found late
## TIER 5 — mature programme
[ ] Row-level security in the database
[ ] Sender-constrained tokens for high-value operations
[ ] Threat model per feature, committed next to the code
[ ] Quarterly self-assessment, documented
[ ] Annual external test
# If you have limited time, tier 1 is worth more than tiers 3, 4 and 5
# combined. Broken authorization is the finding, over and over.Evidence for each line, for auditors
The accepted-risks table with a named owner and a review date is what distinguishes a considered security posture from one that simply never got around to something.
# security/controls.md — the mapping auditors ask for, and the one that keeps
# your own team honest about which controls actually exist.
| Control | Implementation | Evidence | Owner | Verified |
|---|---|---|---|---|
| Object authorization | Scoped repositories + Postgres RLS | `test/authz-matrix.test.js` (1,860 assertions, generated) | @platform | CI, every commit |
| Tenant isolation | RLS policy + `withTenantTx` | `test/tenancy.test.js`; `SELECT relforcerowsecurity` assertion | @platform | CI |
| Function authorization | `adminRouter` with `requireRole` | `test/bfla.test.js`, generated from the inventory | @platform | CI |
| Input validation | Zod `.strict()` on every route | `test/validation.test.js`; OpenAPI validator middleware | @api-team | CI |
| Output shaping | `Serializer.define` + boot-time assertion | `test/exposure.test.js` field scanner | @api-team | CI |
| Password storage | Argon2id (19MiB, t=2) | `src/auth/password.js`; `test/password.test.js` | @auth-team | CI |
| Rate limiting | Redis token bucket, per IP + account | `test/ratelimit.test.js`; Grafana dashboard | @platform | CI + monitoring |
| TLS | TLS 1.2+, HSTS, automated certs | Weekly `testssl.sh`; expiry alert at 30 days | @infra | Weekly |
| Secrets | AWS Secrets Manager; OIDC in CI | gitleaks in CI; no static cloud keys in the org | @infra | CI |
| Audit logging | Append-only, hash-chained | `verify_audit_chain()` nightly | @platform | Nightly |
| Dependency management | npm audit + Snyk, daily | CI job `security/dependencies` | @platform | Daily |
| Inventory | Generated in CI; owner required | `route-owners.json`; build fails on unowned | @platform | CI |
| Incident response | Runbook with commands | `runbooks/`; game day 2026-07-15 | @security | Quarterly |
| Self-assessment | Documented, scoped | `security/assessments/2026-08-04.md` | @security | Quarterly |
| External test | Annual | `security/pentest-2026-03.pdf` | @security | Annual |
## Accepted risks
| Risk | Rationale | Accepted by | Review |
|---|---|---|---|
| Access tokens valid up to 10 min after revocation | tokenVersion check would add a lookup per request; 10 min is acceptable for our data | @cto 2026-06-01 | 2027-06-01 |
| No sender-constrained tokens | DPoP deferred; access tokens are short-lived and in memory | @security-lead 2026-08-04 | 2026-12-01 |
# The accepted-risks table is the most valuable part. A documented, owned,
# dated decision is a decision. An undocumented one is an oversight, and the
# difference matters enormously after an incident.
Discussion