Seven Guards in a Row: How Authorization Grew Across My NestJS APIs
Eleven NestJS APIs, and the guard stack grew the same way in each: two guards, then three, then seven. What that progression teaches about ordering, and the fail-open bug hiding where row-level security met a permissive default.

Authorization starts as one line. @UseGuards(AuthGuard) — done. The token is valid, the user is real, let them through.
Then someone asks for an admin-only endpoint. Then admin is not specific enough. Then the product becomes multi-tenant, and suddenly “is this user allowed?” is not one question at all — it is six, and they have to be asked in order.
I went back through eleven NestJS APIs I have built and lined up how the guard stack grew in each. It turned out to be a straight line:
- Two guards — auth, then roles
- Three guards — auth, verified, then permissions
- Seven guards — the full tenant-aware chain
None of it was designed up front. Each layer showed up the week a requirement did.
Stage one: a role is a string
The first version is the one everybody writes:
const requiredRoles = check<string[]>(ROLES_KEY) ?? [];
if (requiredRoles.length && !requiredRoles.includes(user.role)) {
throw new ForbiddenException("Insufficient role");
}user.role is a string. The guard compares it against whatever @Roles('admin') put on the handler. Twenty lines, and genuinely enough for a while.
It stops being enough at a predictable moment: when you want a role that can do most of what an admin does. You add editor. Then editor needs one admin power, so you add senior-editor. The role list quietly becomes a list of people rather than a list of jobs.
Stage two: permissions, and the first ordering problem
The fix is to stop asking who someone is and start asking what they may do. Roles become bags of permission codes, and the guard checks codes:
const userPermissions = new Set(user.role.permissions.map((p) => p.permission.code));
const required = check<PermissionCode[]>(PERMISSIONS_KEY) ?? [];
if (required.length && !required.some((p) => userPermissions.has(p))) {
throw new ForbiddenException("Insufficient permission");
}Around the same time a second guard appeared for an unrelated reason: unverified users could sign up and start calling the API immediately. That became VerifiedGuard, and it introduced the constraint that shapes everything after it.
VerifiedGuard reads req.user.is_verified. It can only do that because AuthGuard already ran and put user on the request. The guards are no longer independent checks — they are a pipeline, and the order is part of the logic.
Stage three: one deployment, many tenants
Multi-tenancy is where the stack stops being a stack of checks and becomes a stack of context. The questions multiply:
- Is the token valid? → AuthGuard
- Has this user verified their email? → VerifiedGuard
- Which tenant are they acting inside right now, and are they a member of it? → EntityContextGuard
- Is this module switched on for that tenant? → ModuleGuard
- Is this feature flag on for this user in this tenant? → FeatureFlagGuard
- Do they hold the permission this endpoint needs? → PermissionGuard
Registered globally, in order:
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: VerifiedGuard },
{ provide: APP_GUARD, useClass: EntityContextGuard },
{ provide: APP_GUARD, useClass: ModuleGuard },
{ provide: APP_GUARD, useClass: FeatureFlagGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
]NestJS runs global guards in registration order, and that order is load-bearing. Each guard leaves something behind for the next one:
- AuthGuard — reads the bearer token, attaches
req.user - VerifiedGuard — reads
req.user, attaches nothing - EntityContextGuard — reads
req.userand the tenant header, attachesreq.entityIdandreq.entityPermissions - ModuleGuard — reads
req.entityId - FeatureFlagGuard — reads
req.entityIdandreq.user - PermissionGuard — reads
req.userandreq.entityPermissions
Move EntityContextGuard after ModuleGuard and nothing throws a compile error. You just get a ForbiddenException on every module-gated endpoint, because req.entityId is not there yet. That is a genuinely unpleasant afternoon.
The part that nearly bit me
ModuleGuard asks a simple question: is this module enabled for this tenant? It looks up one row.
But that table is protected by Postgres row-level security. The policy filters rows by the current tenant, which is set per transaction through a helper:
const record = await this.prisma.forEntity(entityId, (tx) =>
tx.entityModule.findUnique({
where: { entity_id_module: { entity_id: entityId, module } },
}),
);Query it without going through forEntity and RLS does exactly what you told it to: it hides the row. The guard sees nothing.
And nothing means no row, and no row means enabled — because the default is deliberately permissive, so tenants that predate a module keep working until someone switches it off explicitly.
So the bug is not an error. It is every module silently reporting itself as enabled, for every tenant, indefinitely. RLS was working perfectly. The permissive default was reasonable. Together they fail open.
The lesson I took from it: when a check reads from a filtered source, a permissive default stops being a convenience and becomes a security decision. Either the read has to be guaranteed to see the truth, or the default has to flip.
Two defaults, pointing opposite ways
Which is why the two gate guards in this codebase disagree on purpose.
ModuleGuard is permissive. No row means enabled. Modules are provisioning: a tenant signs a contract and gets a set of them. Defaulting to off would mean every existing tenant loses everything the day the guard ships.
FeatureFlagGuard is dark. An unknown flag is off. Flags are for unfinished work, and the failure mode of an unknown flag defaulting to on is shipping something half-built to everybody.
Same shape, same file layout, opposite defaults — and each is right for what it gates. The question is not “what is the safe default”, it is “what does this thing mean when nobody has said anything about it yet”.
Global guards that do nothing by default
Seven global guards sounds heavy. It is not, because each one opts out on its first line unless a handler asked for it:
const module = check<string>(REQUIRE_MODULE_KEY);
if (!module) return true;The decorators are the whole interface:
@Public() // skip the chain entirely
@AllowUnverified() // signed in, email not confirmed yet
@RequireEntity() // the tenant header is mandatory here
@RequireModule('inventory')
@RequireFeature('new-reporting')
@Permissions('invoice.create')A plain handler with no decorators still runs through all seven, and six of them return true immediately. The cost is negligible; the benefit is that adding a check to an endpoint is one decorator rather than one more thing to remember to wire up.
There is a subtler benefit. Because the guards are global, the default for a new endpoint is protected. If authorization were opt-in, the failure mode of forgetting would be an open endpoint. Here, forgetting gives you an endpoint nobody can reach — annoying, and enormously better.
Would I build all seven again?
Not on day one. The seven-guard version exists in two of my eleven projects, and both are multi-tenant systems with per-tenant provisioning. The other nine are fine with two or three, and adding the rest would be cost with no buyer.
What I would do earlier is much smaller: the first time two guards depend on each other, decide that the chain is a pipeline and write down what each link puts on the request. That list above took ten minutes and would have saved the afternoon I spent staring at a ForbiddenException that was really just guards in the wrong order.
Authorization is not one check. It is a sequence, and the sequence is the design.


