Tutorial: build a multi-user API without BOLA
We will build a small projects API where Alice and Bob use the same endpoints but can access only their own records. The authentication adapter and repository are deliberately replaceable. Swap the demo token verifier for any identity provider and swap the in-memory repository for any database without changing the authorization policy.
- 01Verify identitytoken to Principal
- 02Check functionprojects:read/write
- 03Scope repositoryid + principal.userId
- 04Attack itAlice requests Bob's id
- 05Fail closed404 + no data leak
1. Scaffold the project
2. Normalize identity into an application principal
Your identity provider verifies login and issues a token or session. The backend adapter converts that provider-specific identity into one application principal. Authorization code below this boundary should not care which provider produced it.
In production, the verifier validates a JWT or session and maps the external (issuer, subject) pair to an immutable internaluserId. Do not use email as the ownership key.
For this tutorial, use deterministic test identities. These tokens are fixtures, not an authentication design:
3. Make the repository owner-scoped
This interface has no ordinary request-path findById(). Every operation that touches an existing project requires the trusted owner ID:
A real repository should put id and ownerId in the same database query or write condition. Do not load the row by ID and hope every caller remembers a separate ownership check.
4. Build routes that cannot choose an owner
Request schemas expose only ordinary editable properties. Ownership comes from state.principal, and response schemas omit the internal ownership key:
5. Serve the application
Alice can read alice-1. Changing only the path to bob-1must not turn a valid Alice token into access to Bob's project:
6. Add the tests the happy-path tutorial usually forgets
7. Swap adapters without changing policy
Replace demoVerifier with your OIDC, JWT, session, or API gateway verifier. Replace ProjectRepository with your ORM or database adapter. Keep these invariants:
- The authentication adapter produces a trusted internal
userId. - Route permissions decide whether the operation may be attempted.
- Repository operations constrain rows using the principal's owner or tenant identity.
- Request schemas never accept privileged ownership fields from an ordinary caller.
- Two-principal tests prove that isolation survives refactors.
For tenant-owned data, add the trusted tenantId to the principal and to every repository method. For administrative access, create a separate permissioned and audited repository path rather than silently removing the owner constraint.
Next steps
Read the complete resource authorization guide, then connect the same boundary to your authentication provider, your ORM, and your tenancy model.