Resource authorization: prevent BOLA and IDOR
Authentication proves who called the API. Scopes and roles decide which operations that identity may attempt. Resource authorization decides whether the caller may act on this particular record. A valid token with a valid projects:readscope must not let Alice read Bob's project by changing an ID in the URL.
OWASP calls this Broken Object Level Authorization (BOLA). It is also commonly called an Insecure Direct Object Reference (IDOR). DaloyJS can verify identity, validate identifiers, enforce scopes, and carry a typed principal into the handler. Your application must still connect that principal to its own ownership rules.
- 01identityAuthenticateissuer + subject
- 02functionCheck permissionprojects:read
- 03objectScope the lookupid + owner + tenant
- 04propertyAllow safe fieldsname, description
The three authorization layers
| Layer | Question | Typical control |
|---|---|---|
| Function-level | May this caller invoke the operation? | Scope, role, or permission such as projects:write |
| Object-level | May this caller access this exact record? | Query constrained by resource ID plus trusted owner or tenant |
| Property-level | Which fields may this caller read or change? | Strict request and response schemas |
These layers are cumulative. Hiding an Edit button in React is useful interface behavior, but it is not authorization. Attackers call the API directly.
The vulnerable pattern
This handler is authenticated and checks a scope. It is still vulnerable because the database lookup trusts the caller-controlled identifier by itself:
A valid token does not make every identifier safe. The ID is input, not proof of ownership.
Use one provider-neutral principal
Normalize Auth0, Cognito, Entra ID, Clerk, a self-hosted OIDC provider, or a signed session into one application-level principal. Keep the provider adapter at the authentication boundary and keep business policies independent of it:
Map the pair (issuer, subject) to an internal immutable user ID. A subject is unique within its issuer, not necessarily across every provider. Do not use an email address as the ownership key: addresses can change, aliases can collide, and verification rules differ between providers.
Put the ownership requirement in the repository boundary
A repository API that exposes only owner-scoped operations makes the intended policy visible to humans and coding agents. Its implementation can use Prisma, Drizzle, TypeORM, SQL, MongoDB, DynamoDB, or an in-memory test double:
A generic findById() may still exist for trusted internal jobs, but ordinary request handlers should not reach for it by default. Make the safe operation the easy operation.
Safe CRUD patterns
List: scope the query
Do not load every row and filter in application memory. The data store should never return another owner's rows to the request path.
Read: combine ID and ownership
Returning the same 404for a missing record and an inaccessible record avoids confirming that another user's record exists. Use 403 only when revealing existence is an intentional product decision.
Create: derive ownership from the principal
The client may suggest a name. It must not select its owner, tenant, account role, approval state, or other privileged fields.
Update and delete: constrain the write itself
Prefer a single owner-constrained update or delete. A separate "load, check, then mutate by ID" sequence is easier to weaken during later refactoring and can introduce a time-of-check to time-of-use gap when ownership is mutable.
Authorize fields as well as records
Object ownership does not make every property writable. Reject privileged keys with a strict request schema and declare a response schema that omits secrets and internal state:
Tenant-owned resources
Tenant identity and tenant authorization are different. Resolve the tenant from a verified claim, membership lookup, trusted subdomain, or another non-spoofable source. Then include it in every data operation:
A caller-supplied x-tenant-id header is not proof of membership. See Multitenancy for trusted tenant resolution and per-tenant rate-limit, cache, idempotency, and concurrency partitions.
Make administrator bypasses explicit
Do not hide an administrator bypass inside a repository method named findProject(). Give it an explicit policy name, require a separate permission, and write an audit event:
The minimum adversarial test matrix
Use at least two principals. A one-user test suite cannot prove isolation:
| Case | Expected result |
|---|---|
| No credentials | 401 |
| Valid principal, missing operation permission | 403 |
| Alice reads Alice's record | 200 |
| Alice reads or mutates Bob's record | 404 |
| Alice lists records | Only Alice's permitted records |
Alice submits Bob's ownerId | 422 or the field is rejected |
| Cross-tenant identifier | 404 |
| Administrator bypass | Success plus an audit event |
Continue with the multi-user projects API tutorial for a complete provider-neutral, repository-neutral implementation and executable Alice-versus-Bob tests.
Review checklist
- Every protected route has an explicit operation permission.
- Every identifier route classifies the resource as public, user-owned, tenant-owned, shared, or administrator-only.
- Ownership and tenant keys come from a trusted principal, never an ordinary request body.
- Read and write operations are constrained in the repository or data query, not only after loading the record.
- Request and response schemas enforce property-level authorization.
- Cross-user and cross-tenant tests cover reads, writes, lists, and deletes.
- Privileged bypasses are named, permissioned, and audited.
Also review the OWASP API Top 10 mapping, authentication overview, and testing guide.