Skip to content

Search docs

Jump between documentation pages.

Browse docs

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.

One protected request, four decisions
  1. 01identityAuthenticateissuer + subject
  2. 02functionCheck permissionprojects:read
  3. 03objectScope the lookupid + owner + tenant
  4. 04propertyAllow safe fieldsname, 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

LayerQuestionTypical control
Function-levelMay this caller invoke the operation?Scope, role, or permission such as projects:write
Object-levelMay this caller access this exact record?Query constrained by resource ID plus trusted owner or tenant
Property-levelWhich 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 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

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

CaseExpected result
No credentials401
Valid principal, missing operation permission403
Alice reads Alice's record200
Alice reads or mutates Bob's record404
Alice lists recordsOnly Alice's permitted records
Alice submits Bob's ownerId422 or the field is rejected
Cross-tenant identifier404
Administrator bypassSuccess 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.