SecurityGENERALSCALE-SPECIFIC

The Backend Security Checklist

The controls every service owes no matter what it does, and the layer each one has to live in.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What does every backend service owe, independent of what it is for?

The requirement

A new service is going to production next week. Someone has to say what "secure enough to ship" means without turning it into a six-month programme.

The obvious build

We will get a penetration test before launch and fix what it finds. Security is a phase near the end, done by people who specialise in it.

Why it breaks

A pentest finds what it can reach in five days. It will not find the object-level authorization gap on the endpoint nobody demoed, and that is the one that leaks every tenant.

How it breaks in production
  • A pentest finds what it can reach in five days. It will not find the object-level authorization gap on the endpoint nobody demoed, and that is the one that leaks every tenant.
  • Findings arrive after the design is frozen, so the fixes are patches on top of a structure that made the bug easy — the same class reappears in the next endpoint.
  • Nothing in the list is expensive when it is a habit. Everything in it is expensive when it is a remediation ticket six weeks before an audit.
  • The controls that matter most — authorization on every object, secrets never logged, parameterized queries everywhere — are enforced by how the code is written, not by a report.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Almost every serious backend vulnerability is one of two failures: input was trusted or the caller was not checked against the object. Injection, SSRF, path traversal and mass assignment are the first. IDOR, tenant leakage and privilege escalation are the second.
  • Each control belongs at a specific layer, and putting it at the wrong one makes it decorative. A shape check at the edge cannot enforce a business rule; a gateway token check cannot enforce ownership of row 4711.
  • Controls compose multiplicatively when they are independent and additively when they are the same idea in two places. Two validation libraries are one control; a parameterized query plus a read-only database user are two (Defence in Depth).
  • The checklist is short because it is the intersection of "every service needs it" and "the application is the only place it can be enforced". Anything a platform genuinely handles is not on it.

Eleven controls, and the layer each one lives in

The value of the list is not the items — most engineers can name them. It is the second column: the layer where the control is actually enforceable. A control implemented one layer too high is a control that a single new code path bypasses.

Read the third column as the specific consequence. "Insecure" is not a consequence; "any authenticated user reads any tenant's invoices by changing one integer" is, and it is the one that gets written up.

ControlWhere it has to be enforcedWhat an attacker gets without it
AuthenticationMiddleware, before any handler, with public routes declared explicitlyEvery endpoint as an anonymous caller
AuthorizationAt the point the object is loaded, per object, from the session principalEvery other user's and tenant's data by changing an id (Object-Level Authorization)
Input validationThe process boundary, converting to domain typesWhatever the downstream sink does with unexpected input (The Trust Boundary)
SecretsRuntime configuration and a secret store — never image, repo or logYour database, your provider accounts, your signing keys (Secrets Are Not Configuration)
SQL injectionThe query call site: parameters, never concatenationRead or write of everything that database user can reach (SQL Injection)
Command injectionThe process-spawn call site: argument arrays, no shellCode execution as your service account (Command Injection)
SSRFThe HTTP client plus network egress policyYour internal network and cloud credentials from the metadata endpoint (SSRF — When the Backend Fetches a URL)
File uploadsHandler and storage layer: size, content-derived type, server-generated keyStorage overwrite, path traversal, or a payload served back to other users (File Uploads Through the Backend)
Rate limitingBefore authentication's expensive part, keyed on a stable identityCredential stuffing and enumeration at whatever rate they can send (Rate Limiting)
Logging disciplineThe logging call site, with redaction as a second layerCredentials and personal data spread across every downstream log system (Secrets in Logs)
Dependency securityLockfile, CI, and a patch process with a known latencyA published exploit against a version you are still running (Dependency Security)

The request path, annotated with where each control sits

GENERALThe order holds for any request-response backend. In a queue consumer the same controls apply with the message as the untrusted input and the enqueuing principal as the subject of the authorization decision.

Ordering is a correctness property here, not a style choice. Rate limiting after password verification means the expensive part runs for every attempt. Authorization before the object is loaded means the check runs against an id rather than a record, which is how ownership checks quietly become nothing.

The step that surprises people is the last one. Output is a control surface: error responses leak stack traces and internal hostnames, and serialization leaks fields the client was never meant to see (Not Leaking Your Internals, Three Models, Not One).

Controls in path order
  1. 1
    Edge limits

    Body size, header size, connection and request caps.

    fails by Defaults treated as tuned values; one large upload exhausting an instance (Request Bodies and Streaming).

  2. 2
    Rate limit

    Bounds attempts before expensive work.

    fails by Placed after authentication, or keyed on an unfiltered forwarding header the client controls.

  3. 3
    Authenticate

    Establishes the principal or rejects.

    fails by A route added outside the pipeline; a token verified for signature but not for audience or expiry (Token Authentication and the Revocation Problem).

  4. 4
    Validate + parse

    Turns untrusted bytes into checked domain values.

    fails by Body validated, query and headers not; the raw request passed deeper anyway.

  5. 5
    Load object

    Fetches the record the request names.

    fails by Loading by id alone rather than by id scoped to the principal's tenant.

  6. 6
    Authorize

    Decides whether this principal may do this to this object.

    fails by Role checked, ownership not; check in a SELECT but not in the following UPDATE.

  7. 7
    Act

    Business logic: queries, subprocesses, outbound fetches.

    fails by Concatenated SQL, shell strings, unrestricted outbound URLs.

  8. 8
    Respond + log

    Serializes a result and records what happened.

    fails by Internal errors echoed to the client; the whole request object handed to the logger (Secrets in Logs).

The two omissions that account for most of the damage

Injection is the famous one, but modern query APIs make it hard to get wrong by accident. The two failures that show up again and again in real incident reports are an authorization check that never looked at the object, and a credential that ended up in a log.

They share a property that makes them dangerous: the service behaves perfectly. There is no error, no alert, no latency change. The first is discovered when someone changes an id out of curiosity; the second when a credential appears in a log export or a support ticket.

The check that is not a check
Role only
if (!session.roles.includes('member')) return res.status(403).end()
const invoice = await invoices.findById(req.params.id)
return res.json(invoice)
Object scoped to the principal
const invoice = await invoices.findOne({
  id: req.params.id,
  tenantId: session.tenantId,   // from the verified session, never the body
})
if (!invoice) return res.status(404).end()
return res.json(toInvoiceDto(invoice))

The first answers "is this caller a member of something", which every customer is. The second answers "is this caller a member of the thing that owns row 4711", which is the actual question. Scoping the load means there is no window between check and use, and returning 404 rather than 403 avoids confirming that the id exists.

How to build it

Most important first.

What can go wrong

Failure modes
  • The checklist becomes a spreadsheet signed off once, describing the service as it was three quarters ago.
  • A control is implemented in middleware and then bypassed by an internal route, a batch endpoint or an admin tool that does not go through the pipeline (Middleware Ordering Is a Correctness Decision).
  • Rate limiting placed after authentication, so unauthenticated brute force still costs a full credential verification per attempt.
  • Validation that rejects bad shapes and then hands the raw request object deeper anyway, so the checked value and the used value are different objects.
  • A control that fails open: the authorization service times out and the request proceeds (Fail Open vs Fail Closed in Security Engineering).
What can race
  • Check-then-act on a permission is a TOCTOU gap: a role revoked between the authorization check and the write still allows the write. Enforce ownership in the statement — a WHERE tenant_id = $1 on the update — rather than only in a prior SELECT (Object-Level Authorization).
  • Rate limit counters incremented non-atomically under concurrency let a burst through the limit that a serial trace would have blocked (Atomic Operations).
Security
  • The two most damaging omissions are consistently object-level authorization and secrets in logs. Both are invisible in normal operation, and both are total when they fail — one leaks every tenant, the other leaks a credential into every downstream log system.
  • A missing control is not a probability, it is a standing invitation: the endpoint without an ownership check does not fail sometimes, it fails every time someone changes the id.
  • Everything reachable is in scope, including the health endpoint, the metrics endpoint, the admin tool and the internal API on the private network (Attack Surface in Security Engineering).
Misreads
  • "We have authentication, so we have access control." Authentication says who is calling. Authorization says what they may touch. They are separate systems and the second is the one that leaks data (Authentication vs Authorization).
  • "The WAF covers injection." A WAF sees patterns in requests. It does not know that this user may not read that invoice, and it cannot see the query your ORM builds.
  • "It is an internal service, so most of this does not apply." Internal describes the network path. It says nothing about the data, and lateral movement is the normal shape of a real incident.
  • "Security is the security team's job." Everything on this list is enforced in application code, which the security team does not write.

Operating it

How you see it in production
  • Count authorization denials by principal and endpoint. Zero denials on an endpoint that has an ownership check usually means the check is unreachable, not that everyone is well behaved.
  • Count validation rejections by field and by client. A single client suddenly failing on one field is a broken deploy; many clients failing on many fields is probing.
  • Alert on authentication failures per principal and per source, not in aggregate — the aggregate hides a targeted attempt inside normal noise.
  • Track time-to-patch for dependency advisories as a number your team can say out loud. It is the only honest measure of that control.
What changes at 10x and 100x
  • Nothing on the list gets less necessary with growth, but enforcement mechanism changes: at five endpoints a code review holds the line; at five hundred it has to be a type, a lint rule or a test.
  • More services means the controls have to be inherited rather than reimplemented — a shared middleware package, a service template, a CI check — or the newest service is always the weakest.
  • At scale the interesting question stops being "is the control present" and becomes "how would we know if it were removed". That question is answered by tests and metrics, not by documentation.
What this costs
  • Strict validation and per-object authorization add code to every handler and latency to every request. The alternative is a smaller codebase with a class of bug you cannot review your way out of.
  • Enforcing controls structurally — a repository API that cannot be called without a tenant, a query builder that cannot interpolate — costs flexibility. That is the point, and it is a real cost when a legitimate case needs the escape hatch.
  • A checklist invites the belief that finishing it means done. It is a floor, and floors are not ceilings.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe list holds for any backend exposed to any caller. What differs by stack is the enforcement mechanism — a type system, a lint rule, a middleware — not the requirement.
  • SCALE-SPECIFICBelow roughly one team, review and habit enforce these. Above it, anything not enforced by a test, a type or a CI check drifts within a quarter, because the person who knew the rule is no longer in every pull request.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — a security control without a test that fails when it is removed is a comment.