Key takeaways

  • Authorize every resource access on the server.
  • Scope database operations to identity and tenant context.
  • Test cross-user, cross-role, cross-tenant, and field-level abuse.
  • Treat exports, jobs, and GraphQL resolvers as first-class access paths.
  • Log denials for high-risk actions without leaking the forbidden object.

Authentication is not authorization

A valid session answers who is making a request. It does not answer whether that identity may read this invoice, update this team, approve this refund, or set this field. The dangerous shortcut is placing authentication middleware on a route and assuming every operation beneath it is protected.

Object-level failures appear when an attacker changes an identifier. Function-level failures appear when a lower-privileged user reaches an administrative operation. Tenant failures appear when a query accepts a customer-controlled tenant identifier. Field-level failures appear when mass assignment lets a caller write properties the interface never displayed.

OWASP continues to rank broken access control first because these mistakes are cheap to introduce and expensive to notice. Happy-path tests pass. The UI hides the button. The API still performs the write. Reviewers who only click through the product never see the alternate identifier.

Use a complete authorization decision

A reliable decision includes the authenticated subject, requested action, specific resource, and relevant environment. “User is logged in” is incomplete. “User 42 may update billing settings for tenant 8 because they hold BillingAdmin in tenant 8 and the account is active” is enforceable and auditable.

Central policy code can improve consistency, but it does not remove the need for complete mediation. Every path to a protected resource—including exports, background jobs, GraphQL resolvers, bulk endpoints, and cached representations—must invoke the decision.

Write the decision so a reviewer can name the four parts without reading framework internals. If the team cannot say who, what action, which object, and on what tenant, the control is a hope attached to a decorator.

  • Derive identity and tenant from trusted server context.
  • Load or mutate resources through an authorization scope.
  • Deny by default when policy data is missing or unavailable.
  • Log denied high-risk actions without leaking sensitive values.

Make the database query carry the boundary

Checking access and then issuing an unscoped update creates a time-of-check/time-of-use gap and makes future refactoring dangerous. Prefer a query whose predicates include both the resource identifier and the authorized ownership or tenant boundary. A zero-row update becomes a safe not-found-or-not-authorized outcome.

Do not accept tenant IDs, roles, prices, approval status, or ownership fields from the client as authority. Clients can request an action; the server reconstructs the security-relevant context from authenticated identity, policy data, and trusted records.

This habit also survives refactors. A new export job that reuses the scoped repository method inherits the boundary. A new export job that copies an unscoped find-by-id does not. Shared data access is how authorization scales past the first handler.

Test like an attacker changes one thing

Start with a legitimate request, then change one security-relevant dimension at a time. Replace the object ID with another user’s object. Keep the object and lower the role. Move the identity to another tenant. Add a forbidden field. Call the endpoint through an alternate HTTP method or bulk route. These tests are inexpensive and expose the assumptions happy-path tests preserve.

Name the tests after the abuse, not the framework. “rejects cross-tenant invoice read” tells the next engineer what must remain true. “returns 404 for unknown id” can hide a leaky 403-versus-404 debate and still miss tenancy.

Run the same cases against every new interface to the same resource. A correct REST handler and a broken RPC or job consumer is a common production pair.

  • Same role, different owner.
  • Same user, different tenant.
  • Lower role, same resource.
  • Forbidden property in an otherwise valid payload.
  • Missing or stale policy dependency.

Code-review questions that scale

Reviewers should ask where identity originates, where policy is evaluated, how the data operation is scoped, what the default failure behaviour is, and which negative test proves isolation. This compact checklist works across frameworks because it targets the decision rather than a specific middleware API.

Flag any handler that loads a record by client-supplied ID and then “checks the owner” in application code after the load. That pattern is where forgotten branches live. Prefer “load within scope” over “load then decide.”

Also ask what the client is allowed to write. If the serializer accepts a role, tenantId, price, or approved flag, you are one overlooked field away from a privilege change that no route guard will see.

Watch the paths that skip the UI

Access control bugs cluster on interfaces humans do not click: CSV exports, admin impersonation, search indexes, webhook callbacks, GraphQL aliases, and batch “update many” endpoints. The UI can hide a button and still leave the operation callable.

Search and reporting are frequent offenders. A query that filters the list view correctly may still return another tenant’s row when the detail or export uses a raw identifier. Treat search hits as resources that require the same decision as a direct fetch.

Background work needs an explicit actor. A job that “runs as system” and accepts a tenant ID from the queue payload is a confused-deputy problem waiting for a poisoned message. Bind the job to a recorded identity and a server-side tenant scope.

Fail closed and leave an audit trail

When the policy store is slow or unavailable, default deny for high-risk writes. A brief outage that refuses refunds is preferable to an outage that approves them for anyone still holding a session cookie.

Log the denial with enough context to investigate: actor, action, resource type, tenant, and decision identifier. Do not log the other customer’s payload, secrets, or a stack that reveals unused internal IDs.

Use consistent status behaviour so the API does not become an oracle. Many teams choose not-found for cross-tenant reads so enumeration is harder. Whatever you choose, apply it uniformly and test it.

Put it into practice

Practice authorization failures safely

Use the Playground and Live Labs to reproduce cross-user and cross-tenant access defects, repair them, and test the boundary—without touching production data.

Start practicing