Decide every request as subject, action and resource: take the subject from the verified credential, load the resource from your database, and allow only what an explicit rule permits. Check ownership and tenant on every object (this prevents BOLA, also called IDOR), allowlist writable fields, and check each function’s role. Roles, attributes and relationships can all express the rules; whichever you choose, keep the decision in one tested policy that every entry point calls.
- Authentication supplies the subject; authorization decides this action on this resource now.
- Load resource facts from the database inside the caller’s tenant scope, never from the request body.
- BOLA (API1), field-level BOPLA (API3) and function-level BFLA (API5) are separate checks; test each.
- RBAC, ABAC and ReBAC are ways to express rules; mature systems usually combine them.
- Deny by default when a fact is missing, and enforce state conditions in the same transaction as the change.
- Policy engines help only when every endpoint, job and export calls them with the right facts.
- Request another user’s invoice in your own tenant, and your own invoice ID in another tenant: both must be denied without revealing existence.
- Submit extra fields such as
roleortenantin an update and confirm they are ignored or rejected. - Call every function an ordinary user should not reach, such as approval and export, and confirm
403or404. - List and search endpoints return only rows the caller could read one at a time.
- Run the policy tests in CI so that a rule change without a matching test fails the build.
- Applies to
- Open Policy Agent (Rego example) 1.x, Rego v1 syntax (tested with 1.20.2)
RBAC, ABAC and relationship-aware policy design. Central policy still needs complete enforcement and freshness.
- Part of
- Learning path: Identity through the whole lifecycle (step 8 of 14). Workflows: Fix a finding, Secure a feature, Review a design, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Express authorization using trusted subject and resource facts.
- Separate object access from field-update permissions.
- Verify actual authenticated requests against negative ownership cases.
Before you start
Developers who implement access checks in APIs and services, and reviewers who check object, field and function authorization.
- Express actor/action/object/context decisions and distinguish role from object relationship.
Refresh first: Enforce authorization for every object action
Go to the guided practiceAn authorization decision answers whether this subject may perform this action on this resource in the current context. A valid login or token supplies identity evidence, but the resource’s owner, tenant, state, and relationships determine which operations are allowed. Every value has a source: signed claims, trusted database fields, policy configuration, or untrusted request input.
Role-based access control groups permissions into roles such as billing-reader. Attribute-based rules combine facts such as tenant, document status, or required assurance. Relationship-based rules use links such as owner, team member, or editor. These models can coexist; naming a model does not establish that its implementation evaluates the right resource.
For a private invoice rule, allow read only when claims.tenant equals invoice.tenant and claims.sub equals invoice.owner. The invoice fields come from the trusted database lookup, not the request body. A role alone may be too broad, and a tenant match alone lets peers read one another’s private invoices. Missing this check is broken object level authorization (BOLA), the first risk in the OWASP API Security Top 10 2023 and better known in web applications as insecure direct object reference (IDOR); the fix card at /fix/missing-authorization/ shows the check in each stack.
The models differ in which facts a rule can use, not in how secure they are. Most products start with roles, add attributes such as tenant and status, and use relationships where sharing between users matters. Choose a policy engine only after the decision table exists, because an engine evaluates the facts you give it and cannot find the ones you forgot.
| Model | A rule looks like | Good fit | Engines and services |
|---|---|---|---|
| RBAC (roles) | billing-approver may approve invoices | Stable job functions and admin areas | Framework role checks; Cedar; OPA |
| ABAC (attributes) | Same tenant, status is draft, request within business hours | Tenancy, data classification, state rules; NIST SP 800-162 | OPA with Rego; Cedar (Amazon Verified Permissions) |
| ReBAC (relationships) | Viewer of a document if member of a team that owns its folder | Sharing, nested groups and hierarchies | OpenFGA and SpiceDB, both modeled on Google Zanzibar |
Create a table of actions and resource states. Reading an invoice, changing its payee, approving it, and exporting a tenant’s entire ledger are different permissions. Include exceptional roles and who may grant them. A support agent may need a time-bounded audited grant rather than a permanent global bypass.
Deny by default when required facts are missing or malformed. Decide how to handle deleted users, suspended memberships, stale role claims, and relationship cycles. A cache speeds decisions but can retain access after revocation, so document its invalidation and maximum staleness.
Separate authorization of the target resource from authorization of fields being changed. A caller who may update their display name should not be able to submit role=admin or tenant=other through a generic object merge. An allowlist of writable fields preserves this boundary more clearly than deleting a few known-dangerous fields.
Bulk and list endpoints must apply the same scope as single-object reads. Fetching every row and filtering only in the UI leaks data. Pagination, totals, search suggestions, exports, and error messages can expose unauthorized records even when the final detail page returns denial.
Load or query the resource within the caller’s authorized tenant scope, then evaluate the action against trusted attributes. When possible, express ownership predicates in the database query as well as the policy layer so unauthorized rows are never returned to the caller. Use consistent denial semantics where object existence itself is sensitive.
Mutation authorization can race with state changes. If approval requires status=draft, enforce that condition in the transaction or conditional update that commits approval. Checking draft in one query and writing later can authorize an operation after the invoice has changed state.
Central policy functions improve consistency only if every entry point calls them with the correct facts. Background jobs, GraphQL resolvers, exports, and internal APIs need the same trust analysis. A service-to-service call does not automatically inherit the end user’s authority unless the delegation and scope are explicit.
The “Secure a real HTTP API” project in the local project section below puts this rule into a running service. Use a supported Node.js release (22 or later) and npm, run npm ci --ignore-scripts and then npm test. The project starts a loopback Express API and performs actual JOSE token verification. In exercise.mjs, repair canReadInvoice to require both tenant and owner identity, and selectProfileUpdate to validate and select the allowed displayName field. The update contract requires an object with a string displayName whose trimmed value is nonempty and whose submitted length is at most 80 characters; return the trimmed value in a new object containing no other submitted fields. The IDOR lab at /labs/authorization/ practices the same object check in the browser.
Policy as code puts the decision table in one versioned file with its own tests. This Rego policy for Open Policy Agent allows an owner to read an invoice in their tenant, and an approver to approve a draft in their tenant that is not their own; everything else is denied. The service passes the verified claims as subject and the invoice row it loaded as resource. It was tested with OPA 1.20.2 (opa test): nine cases, including a same-tenant peer, the same owner ID in another tenant, missing resource facts, self-approval, approval after the draft stage, and an unknown action.
package invoices
# Deny unless a rule below proves the request is allowed.
default allow := false
# Read: the verified subject and tenant must match the invoice loaded from the database,
# never values taken from the request body (BOLA, OWASP API1:2023).
allow if {
input.action == "read"
input.subject.tenant == input.resource.tenant
input.subject.sub == input.resource.owner
}
# Approve: an approver in the same tenant, only while the invoice is a draft, never their own.
allow if {
input.action == "approve"
"billing-approver" in input.subject.roles
input.subject.tenant == input.resource.tenant
input.resource.status == "draft"
input.subject.sub != input.resource.owner
}The project’s ordinary owner request returns 200. A peer in the same tenant and a matching owner ID in another tenant both receive 404, matching its non-disclosure policy. Missing invoices also return 404. Invalid signatures, expired tokens, wrong audience or token purpose, and missing credentials return 401 before resource authorization.
Profile updates accept a valid displayName while preserving the existing role and tenant even when those extra properties are submitted. Invalid field types return 400; oversized JSON returns 413; malformed JSON receives a bounded 400 response. These outcomes prove multiple boundaries, so preserve the tests rather than weakening expectations.
Production review must also test revocation, cached facts, bulk operations, and concurrent state changes. The project’s in-memory fixtures and single-process policy do not establish those deployment properties, but they give a concrete foundation for the subject-action-resource rule.
Guided practice
Apply the invoice rule to supplied records, then repair the downloadable project.
Supplied scenario material
Claims: sub=alice, tenant=acme (already cryptographically verified).
Invoice one: owner=alice, tenant=acme, total=80.
Invoice two: owner=bob, tenant=acme.
Invoice three: owner=alice, tenant=other.
Profile body: {displayName:"Al",role:"admin",tenant:"other"}.
Project: the Secure a real HTTP API download on this page; npm ci --ignore-scripts; npm test.1. Build the decision table
Evaluate all three invoices using both required equalities.
Expected result and reasoning
One permits read. Two fails owner equality; three fails tenant equality. A same-tenant-only rule incorrectly permits two.
2. Constrain the mutation
Select and validate only the documented
displayNamefield; leave stored role and tenant unchanged.Expected result and reasoning
The response shows
displayNameAl, role member, tenant acme. A numericdisplayNameis rejected rather than coerced.3. Run the actual boundary checks
Repair
exercise.mjsand run the provided test suite without changing expectations.Expected result and reasoning
The ordinary HTTP request succeeds; ownership/tenant probes deny; real bad-signature/audience/purpose cases remain 401; malformed and oversized input retains its bounded errors.
The project uses real HTTP and JOSE verification with temporary keys. It does not implement production persistence, distributed policy caches, or organization-wide role administration.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Secure a real HTTP API
Run Express requests with real signed tokens. Repair invoice authorization and stop user-controlled properties from changing roles or tenants.
Needs: Internet access for npm ci, which downloads the pinned packages; the tests run on localhost only.
In the extracted folder, run:
npm ci --ignore-scripts
npm test14 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.
Part of the learning path Developer foundations
Localhost tests with synthetic identities and in-memory records; includes actual JOSE verification.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Authorization Cheat Sheet
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- NIST SP 800-162: Guide to Attribute Based Access Control. January 2014, updated August 2019.
- Zanzibar: Google’s Consistent, Global Authorization System (USENIX ATC 2019)
- Open Policy Agent: policy testing
- OWASP ASVS 5.0: V8 Authorization. Version 5.0.0 (May 2025).
Reference information
- SDLC phase
- DesignDevelopment
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-8.2.1 · v5.0.0-8.2.2 · v5.0.0-8.2.3 · v5.0.0-8.3.1 · v5.0.0-8.3.3 · v5.0.0-8.4.1