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.
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.
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.
# 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# 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: deniedThe 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.
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.
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 } });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.
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.
// 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-sideVerified 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.
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.
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.
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.
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)
enddef 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)
endOWASP 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.