Where Authorization Must Live
Not in the frontend, not only at the gateway, and not as an optional call in each handler — but at the point where the resource is loaded, structured so it cannot be skipped.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Complete mediation
The principle: every access to every resource goes through the check. The practical consequence: the check must live *below* the layer where paths multiply. HTTP handlers multiply (REST, GraphQL, websockets, admin routes, batch jobs); a data-access layer is singular. Put the principal requirement there.
The frontend is not an enforcement point — it runs on the attacker's machine. The gateway is a coarse enforcement point — it knows the principal, not the record. Handlers are enforcement points that are easy to forget. The repository is the enforcement point that is hard to bypass.
Make skipping it a compile error
Batch jobs and admin tools that legitimately cross tenants get a distinct, audited entry point with a distinct principal type — so the exception is visible in code review rather than hidden in a nullable parameter.
1class Invoices {2 // No public byId(id). The only entry point takes a principal.3 forPrincipal(p: Principal) {4 return {5 byId: (id: InvoiceId) => this.db.one('SELECT * FROM invoices WHERE id=$1 AND tenant_id=$2', [id, p.tenantId]),6 list: (q: Query) => this.db.many('SELECT * FROM invoices WHERE tenant_id=$1 AND ...', [p.tenantId]),7 }8 }9 // Admin/support access is a DIFFERENT, audited method with a different principal type.10 forSupport(p: SupportPrincipal, reason: string) { audit.log('support.access', { p, reason }); /* ... */ }11}Key points
- Enforce below the layer where paths multiply.
- Frontend checks are UX; gateway checks are coarse; repository checks are complete.
- Make unscoped access unrepresentable.
- Give cross-tenant tooling a separate, audited entry point.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → the path without the check: GraphQL resolver, export, websocket, new route.
- One unchecked path exposes the resource type regardless of how many checked paths exist.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Principal-required data access.
- • RLS as depth.
- • Route policy declared or the app fails to boot.
- • Count data-layer calls without a principal (should be zero).
- • Requester/owner mismatch alerts.
- • Find every caller of the unscoped path; scope from logs.
- • Raw SQL and ORMs offer escape hatches.