Security

Authorization Best Practices: Access Control Enforcement That Holds Across Services

Access control does not usually fail because someone forgot to write a check. It fails because the check was written somewhere a reviewer can pass without it, and because nothing in the codebase distinguishes an endpoint that was deliberately left open from one that nobody thought about.

13 min read · 8 Principles · API Design
Pink hollow rectangular prism sculpture on a pink surface.

TL;DR

  • A route, handler, or message consumer with no declared authorization requirement is a public endpoint, so the framework has to deny anything that does not declare one.
  • Ownership belongs in the WHERE clause of the query that loads the resource, not in a comparison performed after the row is already in memory.
  • Roles, permissions, and tenant come from verified token claims or a server-side lookup, and any value the client can set is a privilege escalation waiting to be noticed.
  • Authorize the named operation being performed, never the route prefix, screen, or client the request arrived through.
  • Every identifier in a request is its own authorization decision, including nested objects, foreign keys, and the privileged fields inside the body.
  • Queues, jobs, and replays are entry points, so the initiating principal travels in the payload and the decision is re-evaluated inside the handler.
  • Authorization scattered across handlers cannot be reviewed as a whole, which is why one policy layer plus codebase-wide enforcement is the only version that survives thirty services.

What Authorization Enforcement Actually Protects

Authorization is the decision about whether an authenticated principal may perform a specific operation on a specific resource. Access control enforcement is the narrower property that this decision is actually made, on every path that reaches the data, in a place a reviewer can find. Authentication answers who the caller is. Authorization answers what they may do, and it is the half that fails silently, because a missing check produces a successful response rather than an error anyone can page on.

None of these principles are about developer carelessness. They are about the fact that an authorization check is a local decision with global consequences, written by an engineer who has all the context and relied upon six months later by an engineer in another service who has none. Broken access control sits at the top of the OWASP Top 10, and it stays there because the failure mode is omission rather than error.

1. A Handler With No Policy Is a Public Endpoint

A new controller ships during a release freeze. No annotation, no middleware entry, no decorator. The route table serves it to anyone holding a valid session, and nothing in the diff looked wrong, because the thing that is missing does not appear on any line. Configure the framework so that a handler with no declared authorization requirement is denied, then require every route, controller action, gRPC method, and message consumer to state its requirement explicitly. The same rule applies one level down, inside the authorization function: the final branch is a denial, and any unrecognized role, permission, resource type, or action returns denial rather than falling through to an allow.

ANTI-PATTERN: authorization is opt-in, and the policy function falls open
# FastAPI: the dependency is opt-in, so a new route is public
@router.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: str):
    return repo.load(invoice_id)

def can(role, action):
    if role == "admin":
        return True
    if role == "auditor" and action == "read":
        return True
    return True                    # unrecognized role: allowed
CORRECTED: undeclared is denied, unrecognized is denied
# every route under this router inherits a requirement
app.include_router(router, dependencies=[Depends(require_declared)])

@router.get("/invoices/{invoice_id}")
@requires("invoice:read")          # explicit, greppable, per-route
def get_invoice(invoice_id: str, principal=Depends(current_principal)):
    return repo.load_for(principal, invoice_id)

def can(role: str, action: str) -> bool:
    return action in POLICY.get(role, frozenset())   # unknown: denied

The cost is that health checks, webhooks, and marketing endpoints now need an explicit public marker, and someone will complain about the ceremony. That complaint is the feature. A handler annotated @public is a decision a reviewer can argue with. A handler with no annotation is an accident nobody can see.

2. Ownership Belongs in the Query, Not the If Statement

Somebody increments an identifier in a URL and reads another tenant's invoice. The access log shows a clean 200 from an authenticated user, so nothing alerts, and the breach is discovered when the other tenant mentions it. This is broken object level authorization, the most exploited API weakness there is, and it survives code review because the load and the ownership comparison are two separate statements that can drift apart. Include the authenticated principal's owner, tenant, or organization identifier in the WHERE clause of the query that loads the resource. The same predicate is mandatory on every export, report, search, and bulk read path, which is where it is most often missing, because those queries are built from filter parameters and a filter feels like a different kind of thing from a permission.

ANTI-PATTERN: load first, compare later, forget entirely on the bulk path
const order = await db.order.findUnique({ where: { id: params.id } });
if (order.tenantId !== session.tenantId) throw new ForbiddenError();
return order;

// the export path, where nobody remembered the comparison at all
return db.order.findMany({ where: { status: query.status } });
CORRECTED: scope is a predicate on every read path
const order = await db.order.findFirst({
  where: { id: params.id, tenantId: session.tenantId },
});
if (!order) throw new NotFoundError();

// same scope, mandatory, on exports, reports, and search
return db.order.findMany({
  where: { tenantId: session.tenantId, status: query.status },
});

Notice what the corrected version returns. A resource that exists but belongs to another tenant comes back as a not-found rather than a forbidden, because a 403 confirms the row is real and turns your identifier space into an enumeration oracle. RFC 9110 leaves the choice to the server, and for anything multi-tenant the answer is not-found. The cost is operator ergonomics: a support engineer debugging a real permission problem sees the same 404 an attacker sees, so the distinction has to live in the denial log instead of the response body. If you are enforcing tenant isolation at the data layer as well, the predicate and the isolation boundary should be the same column rather than two ideas that happen to agree.

3. Permissions Come From the Token, Never the Request

A service reads the caller's role from an X-User-Role header, which the API gateway sets after validating the session. It works, it is fast, and it is correct until the day the service is reachable from inside the mesh, or a second gateway is added, or someone forwards the header from a client request. Any client-supplied role value is an immediate privilege escalation, and it does not look like one in the code, because the read is one line long and the line looks like configuration. Read the caller's roles, permissions, group memberships, and tenant from verified token claims or from a server-side lookup keyed on the verified subject.

CORRECTED: claims from a verified token, never from the request
// each of these is a privilege escalation, not a shortcut:
//   role := r.Header.Get("X-User-Role")
//   role := body.Role
//   role := c.Query("tenant_id")

claims, err := verifier.Verify(ctx, bearerToken(r))  // sig, iss, aud, exp
if err != nil {
    return deny(w, r, "token_invalid")
}
principal, err := directory.Load(ctx, claims.Subject) // roles server-side

Verified means signature, issuer, audience, and expiry, checked by the service that is about to act on the claim, not by something upstream the service trusts by convention. Whether the permission set travels in the token or is looked up on every request is a real design choice. Claims in the token are fast and stateless and go stale the moment an admin revokes something, which matters more the longer your token lifetime is. A server-side lookup is always current and puts the identity store on the critical path of every request. Both are defensible. What is not defensible is the third option that looks like either of them from a distance: a value the caller can set, arriving in a header your gateway is assumed to have overwritten. If you model permissions as attributes rather than flat roles, NIST SP 800-162 on attribute based access control is the reference for what the attribute set has to include and where it may come from.

DIAGNOSTIC: grep the codebase for request header reads whose names contain user, role, tenant, org, or account. Every hit is either a gateway contract you can prove is unreachable from outside, or a privilege escalation with a date on it.

4. Authorize the Operation, Not the Route

An admin console calls DELETE /admin/records/42. The mobile client calls DELETE /records/42. Both reach the same service, one of them was authorized by the fact that the path starts with /admin, and there is no permission named anywhere. Route prefixes, screen names, and client identifiers are not authorization. They are hints about which authorization the developer had in mind.

RULE OF THUMB
If deleting the record requires an admin, and the admin screen calls
/admin/records/42 while the mobile app calls /records/42, you have two
authorization models and one of them is a URL prefix.

Evaluate authorization against a named permission for the operation being performed: record:delete, not "arrived on the admin router." This is what broken function level authorization means in practice, and naming the permission is what makes the check reviewable, because a reviewer can ask whether record:delete is the right permission for this handler and cannot meaningfully ask whether a route prefix is.

The bypass flag deserves its own sentence. Never add a parameter, header, or configuration switch to a shared code path that skips an authorization check. Test hooks, impersonation switches, and internal-caller bypasses all begin as a boolean that one caller passes and end as a boolean that six callers pass, one of which is reachable from a request body. Operations that legitimately run without a user principal belong in a separate, distinctly named service path, so that "this runs unauthorized" is a property of the function you are looking at rather than an argument someone can supply.

5. Every Identifier in the Payload Is a Decision

The route says /projects/42, the check authorizes project 42, and the request body contains owner_id, billing_account_id, and plan. The handler authorized one of the four things the request touches. This is how a well-tested authorization layer still lets a user reassign a project to an account they do not own, or promote themselves by including a field the form never rendered. Perform an authorization check for every resource identifier that appears anywhere in the request, including nested objects, foreign key fields, and collection elements. Then treat privileged fields as their own decision: permission to update a resource is not permission to change its role, permission, status, price, ownership, tenant, or verification field.

ANTI-PATTERN: the route resource is checked, the payload is not
def update
  authorize! :update, @project        # only the thing in the path
  @project.update(project_params)     # body carries owner_id, plan, ...
end

def project_params
  params.require(:project)
        .permit(:name, :owner_id, :billing_account_id, :plan)
end
CORRECTED: every referenced identifier and every privileged field
def update
  authorize! :update, @project
  attrs = project_params

  authorize! :assign, User.find(attrs[:owner_id]) if attrs[:owner_id]
  if attrs[:billing_account_id]
    authorize! :charge, BillingAccount.find(attrs[:billing_account_id])
  end
  authorize! :change_plan, @project if attrs[:plan]   # not :update

  @project.update(attrs)
end

OWASP splits object level authorization from object property level authorization precisely because teams that fixed the first one keep shipping the second. A permitted-parameters list controls which fields the framework will bind. It says nothing about whether this caller may change them, and a strict allowlist gives a reviewer the comfortable feeling that the question has already been handled.

6. Queues and Jobs Are Entry Points Too

A job is enqueued from a handler that checked permission properly. Nine months later the same handler is invoked by a retry, by a dead-letter redrive, by a replay tool an engineer wrote during an incident, and by a second producer in another service that copied the payload shape from a Slack message. The check happened once, in a place the handler cannot see, on behalf of a principal the handler cannot name.

Carry the initiating principal, or an explicit authorization scope, in the payload of every enqueued job and published command, and re-evaluate the decision as the first statement of the handler. A handler that trusts its producer has as many authorization models as it has producers, and that count only goes up.

Re-evaluation is not redundant work, because time has passed. Permissions get revoked between enqueue and execution, employees leave, tenants get suspended, and a job authorized on Tuesday can be executing on Friday against a principal who should no longer be able to trigger it. Decide explicitly what happens when the principal is gone: failing closed and dead-lettering is usually right, silently proceeding never is, and retrying forever turns a permission change into a queue backlog. This pairs directly with idempotency in event-driven applications, since the same replay and retry paths that force you to make handlers idempotent are the ones that force you to make them authorize.

7. Authorization Drifts Unless One Layer Owns It

Twelve services, each with its own idea of how to ask the question. An inline role comparison in a controller here. A hasPermission helper there, with slightly different semantics for the empty-permission case. A template conditional in the admin UI that is the only place a particular rule exists. Every one of those is defensible inside the pull request that introduced it. Together they are an authorization model that nobody in the company can state out loud, which means nobody can audit it, and which means the answer to "who can delete a customer record" is discovered rather than known.

Express every authorization rule in a dedicated policy module, class, or middleware that handlers call, keyed by resource type and action, so handlers ask a question instead of answering one. Then log every denial with the principal, the attempted action, and the target resource, because a denial nobody records is both an attack nobody sees and a permission bug nobody can reproduce.

This is where individual competence stops being enough. A reviewer looking at one pull request cannot see that this service reads roles from a header while that one verifies a token, that three services return 403 where the rest return 404, that the export endpoint added last quarter never got the tenant predicate, or that a bypass flag introduced during an incident is still sitting in the shared path. Google built Zanzibar because a unified authorization system was the only way to get consistent semantics across hundreds of services, and most organizations reach the same conclusion the expensive way. You do not need Zanzibar. You do need one place where the rules live and a mechanism that notices when a repository stops using it. The OWASP Authorization Cheat Sheet makes the same point in one line: validating permissions correctly on most requests is not validating them. That gap, between what is locally reviewable and what is globally true, is what a governance layer exists to close.

How Pandorian Enforces This Guide

Every principle above is a code shape rather than a judgment call. A handler with no authorization declaration, a resource loaded by client-supplied identifier with no owner predicate, a role read from a request header, a job handler with no principal in its payload: each is statically observable, and each is invisible in a diff that shows twenty lines of context. Pandorian's enforcement surfaces run the same guideline package at three different moments.

Caught at PR level

A new route added with no authorization declaration, a resource loaded by client-supplied identifier with no owner predicate, or a role read from a request header surfaces on the pull request that introduces it, with the line, the rule, and a suggested fix, before it reaches a branch anyone deploys.

Full scans

An on-demand scan across the whole codebase establishes the day-one baseline, including every handler that predates the guideline, every inline role comparison, and every export query built from filter parameters alone.

Scheduled scans

A new service that ships with its own permission helper, a bypass flag added to a shared code path during an incident, or a message consumer added outside any reviewed pull request shows up on the next scheduled scan rather than in a penetration test report.

A Taste of the Guideline Package

RULE WHAT IT FLAGS
SEC_46017
A route, controller action, or message handler with no declared authorization requirement, in a codebase where the framework's default for an undeclared handler is to allow the request.
SEC_59248
A resource loaded by a client-supplied identifier where the owner, tenant, or organization column is absent from the query predicate and compared in application code afterwards, or not compared at all.
SEC_53420
A role, permission, tenant, or group membership value read from a request header, body field, query parameter, or cookie rather than from verified token claims or a server-side lookup.
SEC_38759
A queue consumer, job handler, or event subscriber that acts on a resource identifier from its payload without an authorization check at the start of the handler.

FAQ

What is authorization enforcement?

+

Authorization enforcement is the practice of ensuring that every operation on a resource is checked against a policy that says whether the calling principal is permitted to perform it. It covers where the check lives, what it reads the caller's identity from, and whether a path with no check is denied or allowed by default.

What is the difference between authentication and authorization?

+

Authentication establishes who the caller is by verifying a credential, session, or token. Authorization decides what that verified caller may do with a specific resource. A fully authenticated user can still trigger a broken access control vulnerability if nothing checks their permission for the action, which is why the two appear as separate categories in the OWASP Top 10.

What is an insecure direct object reference?

+

An insecure direct object reference, usually shortened to IDOR, happens when an application exposes a resource identifier and loads the resource from that identifier without checking that the caller owns it. Changing the number in a URL or a request body then returns someone else's data. The structural fix is putting the owner or tenant identifier in the query predicate rather than comparing it after the load.

Should an API return 403 or 404 for a resource the caller cannot access?

+

Return 404 when the resource exists but belongs to another principal or tenant, because a 403 confirms the identifier is real and lets an attacker enumerate your resource space. 403 is appropriate when the caller can already see that the resource exists, for example a shared object they lack write permission on. The cost of the 404 convention is debuggability, which is why the denial log has to record the real reason.

How do I tell whether my codebase has an access control problem?

+

Pick one service and count three things: routes with no declared authorization requirement, queries that load a resource by client-supplied identifier without an owner column in the predicate, and export or report endpoints built only from filter parameters. If any count is above zero in a service you consider well maintained, the problem is not that service. It is that nothing in the pipeline was ever asking the question.

Can a linter catch broken access control?

+

Partly. A linter reliably catches single-file shapes: a handler with no authorization annotation, a role read from a request header, a query with no owner predicate, an authorization function whose default branch allows. What it cannot do is tell you that this service verifies tokens while that one trusts a gateway header, that the policy layer three services agreed on has two competing implementations, or that a bypass flag added during an incident is still in the shared path. Those are codebase-wide properties, which is why enforcement has to run across repositories on a cadence rather than only on the diff in front of a reviewer.

Get the Guideline Package

You'll get this as a guideline MD file. Hand it straight to your own coding agent or CI setup. Or skip the DIY and let Pandorian enforce all thirteen authorization rules on every pull request and every scan.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate one handler at a time. Pandorian turns this guide into checks that run on every pull request and across every repository, so the access control model you agreed on stays the access control model you have.