Your new role works. It can sign in, load its dashboard, and call the API. That is exactly when an authorization leak becomes easy to miss.
In one live multi-role build, an external client role passed the same requireAuth() check as internal staff. It could call invoice, client, and expense endpoints that were never meant for it. The repair touched 12 routes.
Nothing was wrong with authentication. The server knew who the caller was. The missing question was whether that caller was allowed to perform this action.
Give privileged routes a privileged guard
This shape proves identity and nothing more:
export async function GET(request: Request) {
const user = await requireAuth(request)
return listOrgInvoices(user.organizationId)
}
For a staff-only endpoint, make the role decision explicit:
const OPERATOR_ROLES = new Set(['super_admin', 'admin', 'member'])
async function requireOperator(request: Request) {
const user = await requireAuth(request)
if (!OPERATOR_ROLES.has(user.role)) {
throw new HttpError(403, 'Forbidden')
}
return user
}
Then use requireOperator() on every route that exposes operator data or actions. The helper name is not important. The centralized decision is.
Avoid scattered checks such as role !== 'client' unless your role model really is closed. A positive allowlist makes a new role fail closed until somebody decides what it may do. If roles can be configured dynamically, use permissions rather than hard-coded labels, but keep the same default-deny behavior.
Keep the database boundary too
Route guards and Row Level Security solve different problems. A route guard controls the operation your application exposes. RLS controls which rows the database role can reach. Supabase describes an RLS policy as an implicit WHERE clause applied whenever a table is accessed.
Use both when possible. This matters especially on server routes that use a service credential, because a service role can bypass RLS. In that case, the route's authorization check is carrying more weight than it appears to.
Test the denial, not only the happy path
Create a route-to-role matrix before release. For every non-public endpoint, record the allowed roles and test at least one denied role. A useful test asserts all three outcomes:
- an unauthenticated request gets
401; - an authenticated but unauthorized request gets
403; - an authorized request succeeds and returns only rows inside its tenant.
The important number from that 12-route repair is not 12. It is the discovery method: once one authenticated external role crossed one boundary, every route using the same guard became suspect. Audit the pattern, not just the endpoint that exposed it.
