# 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:read` scope 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.

**Diagram: One protected request, four decisions**

1. **Authenticate** (identity) - issuer + subject
2. **Check permission** (function) - projects:read
3. **Scope the lookup** (object) - id + owner + tenant
4. **Allow safe fields** (property) - name, description

A login screen solves only the first decision. A scope usually solves the second. The data operation and strict schemas must solve the last two.

## 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:

```ts
// Vulnerable: a valid caller can replace the URL id with another user's id.
app.get(
  "/projects/:id",
  {
    hooks: requireAuth("projects:read"),
    request: { params: z.object({ id: z.string().min(1) }).strict() },
    responses: { 200: { description: "Project", body: ProjectResponse } },
  },
  async ({ params, state }) => {
    return state.projects.findById(params.id);
  },
);
```

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:

```ts
export interface Principal {
  /** Stable application user id, resolved from the external identity. */
  userId: string;
  /** Identity-provider issuer. */
  issuer: string;
  /** Provider subject within that issuer. */
  subject: string;
  /** Optional trusted application tenant. */
  tenantId?: string;
  /** Normalized application permissions. */
  permissions: readonly string[];
}

declare module "@daloyjs/core" {
  interface AppState {
    principal?: Principal;
  }
}
```

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:

```ts
export type ProjectPatch = {
  name?: string;
  description?: string | null;
};

export interface ProjectRepository {
  listForOwner(ownerId: string, tenantId?: string): Promise<Project[]>;
  findForOwner(
    id: string,
    ownerId: string,
    tenantId?: string,
  ): Promise<Project | null>;
  createForOwner(
    ownerId: string,
    input: { name: string; description?: string | null },
    tenantId?: string,
  ): Promise<Project>;
  updateForOwner(
    id: string,
    ownerId: string,
    patch: ProjectPatch,
    tenantId?: string,
  ): Promise<Project | null>;
  deleteForOwner(
    id: string,
    ownerId: string,
    tenantId?: string,
  ): Promise<boolean>;
}
```

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

```ts
const principal = state.principal!;
const projects = await state.projects.listForOwner(
  principal.userId,
  principal.tenantId,
);
```

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

```ts
const principal = state.principal!;
const project = await state.projects.findForOwner(
  params.id,
  principal.userId,
  principal.tenantId,
);

if (!project) throw new NotFoundError("Project not found");
```

Returning the same `404` for 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

```ts
const CreateProjectBody = z
  .object({
    name: z.string().min(1).max(120),
    description: z.string().max(2_000).nullable().optional(),
  })
  .strict();

// ownerId and tenantId are deliberately absent from the request schema.
const project = await state.projects.createForOwner(
  state.principal!.userId,
  body,
  state.principal!.tenantId,
);
```

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

```ts
const updated = await state.projects.updateForOwner(
  params.id,
  state.principal!.userId,
  body,
  state.principal!.tenantId,
);

if (!updated) throw new NotFoundError("Project not found");
```

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:

```ts
const UpdateProjectBody = z
  .object({
    name: z.string().min(1).max(120).optional(),
    description: z.string().max(2_000).nullable().optional(),
  })
  .strict();

const ProjectResponse = z
  .object({
    id: z.string(),
    name: z.string(),
    description: z.string().nullable(),
  })
  .strict();

// Not writable: ownerId, tenantId, role, createdBy, approvedAt.
// Not returned: internal flags, deletedAt, billing metadata.
```

## 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:

```ts
const invoice = await repository.findOne({
  id: params.id,
  tenantId: principal.tenantId,
  ownerId: principal.userId,
});
```

A caller-supplied `x-tenant-id` header is not proof of membership. See [Multitenancy](/docs/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:

```ts
if (principal.permissions.includes("projects:admin")) {
  const project = await projects.findForAdministrator(params.id);
  await audit.write({
    action: "project.admin_read",
    actorId: principal.userId,
    resourceId: params.id,
    requestId: state.requestId,
  });
  return project;
}

return projects.findForOwner(params.id, principal.userId, principal.tenantId);
```

## 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](/docs/tutorials/multi-user-api) 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](/docs/security/owasp-api-top-10), [authentication overview](/docs/auth), and [testing guide](/docs/testing).

---

Source: https://daloyjs.dev/docs/security/resource-authorization