Authentication answers who you are. Authorization answers what you may do, and it splits into two halves that need different tools.
Role checks — "only contractors may submit quotes" — depend on the token and the route, so they can be declared on the route. Ownership checks — "only this project's homeowner may cancel it" — depend on a row in the database, so they cannot.
Most tutorials cover the first half. The second is where the bugs are.
Roles as data
export const UserRole = {
HOMEOWNER: 'homeowner',
CONTRACTOR: 'contractor',
STAFF: 'staff',
} as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]A const object with a derived union type, rather than a TypeScript
enum. The values are plain strings that go straight into a varchar column, the type is
a union so an invalid role is a compile error, and there is no runtime enum object with a reverse
mapping to trip over.
The third role is called staff rather than admin, and that is a
security decision rather than a naming preference. In the GraphQL layer this application uses,
admin is a built-in superuser role that bypasses every permission rule — so a token
whose role is the literal string admin would get unrestricted access to every table.
The general lesson generalises: check whether the systems downstream of you treat any role name as
special before you use it.
Declaring and enforcing
The decorator attaches metadata and nothing else:
export const ROLES_KEY = 'contractor:roles'
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)The guard reads it back:
const required = this.reflector.getAllAndOverride<UserRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
])
if (!required || required.length === 0) return true
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>()
const user = request.user
if (!user || !required.includes(user.role)) {
throw new ForbiddenException('Your account cannot do that.')
}
return trueAnd a route declares what it needs:
@Post()
@Roles(UserRole.HOMEOWNER)
create(@CurrentUser() user: AuthenticatedUser, @Body() dto: CreateProjectDto) {
return this.projectsService.create(user, dto)
}Two details decide how the whole scheme behaves.
getAllAndOverride checks the handler before the class, so a method-level
@Roles wins over a class-level one. Plain get would see only one of the
two, and an override that silently does not override is a bad bug to have in an authorization
path.
And no decorator means no role requirement. Returning false there would 403 every
unannotated route — including the one below, which deliberately has none.
The actor never comes from the body
const project = repo.create({
homeownerId: user.id,
categoryId: category.id,
title: dto.title.trim(),
// ...
})user.id comes from the verified token. dto never carries a
homeownerId, and there is no endpoint in this application that accepts one.
A homeownerId field in a request body would let anyone post a project as somebody
else — and on this application that is enough to then read the quotes it attracts, which are
commercially sensitive. The global ValidationPipe's
forbidNonWhitelisted makes the guarantee structural rather than a convention: a
request carrying that field is a 400 naming it, not a silently ignored extra.
Ownership, and 404 rather than 403
if (!project || project.homeownerId !== user.id) {
throw new NotFoundException('That project no longer exists.')
}Someone else's project returns 404, not 403. That is deliberate and it is worth internalising: a 403 confirms the id exists, which on a guessable identifier turns into a slow enumeration of every job on the site.
Note the condition covers both cases in one expression. "Does not exist" and "is not yours" reach the same throw, so there is no branch where a future edit could make them diverge.
The same idea in a different shape:
const image = await this.dataSource.getRepository(PortfolioImage).findOne({
where: { publicId: imagePublicId, contractorId: profile.id },
})
if (!image) throw new NotFoundException('That image no longer exists.')The ownership is in the query. Loading by id and checking afterwards works too — but only if you remember to, every time. Scoping the lookup means the check cannot be forgotten, because there is no version of this code that finds the row and then decides.
The endpoint with no decorator
The route where both roles are legitimate:
@Patch(':projectId/status')
updateStatus(
@CurrentUser() user: AuthenticatedUser,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: UpdateProjectStatusDto,
) {
return this.projectsService.updateStatus(user, projectId, dto.status)
}A homeowner cancels; the hired contractor starts and completes. Which of them may make a given move depends on the project's current status and on whether this contractor is the one who actually won the job. A decorator is evaluated at startup and has never seen the row, so it cannot express any of that.
The rule lives in the service as a table:
const TRANSITIONS: Array<{
from: ProjectStatusType
to: ProjectStatusType
by: 'homeowner' | 'hired-contractor'
}> = [
{ from: ProjectStatus.HIRED, to: ProjectStatus.IN_PROGRESS, by: 'hired-contractor' },
{ from: ProjectStatus.IN_PROGRESS, to: ProjectStatus.COMPLETED, by: 'hired-contractor' },
{ from: ProjectStatus.OPEN, to: ProjectStatus.CANCELLED, by: 'homeowner' },
{ from: ProjectStatus.QUOTED, to: ProjectStatus.CANCELLED, by: 'homeowner' },
]An allowlist, not a list of forbidden moves. A status added later is closed by default and has to be opened deliberately; with a denylist it would be open by accident, and nothing would point that out.
Having one table also means the lifecycle is described in exactly one place, which is what makes
it possible to trust. Three endpoints — /start, /complete,
/cancel — would read more nicely and would spread the same rules across three
handlers.
Defining "the hired contractor"
const accepted = await manager.findOne(Quote, {
where: { projectId: project.id, status: QuoteStatus.ACCEPTED },
})
if (!accepted || accepted.contractorId !== user.contractorId) {
throw new NotFoundException('That project no longer exists.')
}Who is hired is defined by the accepted quote, not by a column on the project. There is no
hired_contractor_id on purpose — it would be a second copy of a fact the quotes already
record, and the two would eventually disagree.
That is a data-modelling decision that shows up as an authorization decision, which happens more often than it seems. When "who may do this" is derived from data rather than duplicated, there is one answer instead of two that can drift.
Checking the same rule twice
A rule enforced by one system's read permissions is checked again on the write path:
const worksInTrade = profile.categories.some((category) => category.id === project.categoryId)
if (!worksInTrade) {
throw new ForbiddenException(
`Add ${project.category.name} to your services before quoting on it.`,
)
}A contractor only sees leads in trades they work in, so in the user interface this cannot happen. It is checked anyway, because a permission that hides a row does not stop a request that names its id directly.
The general form: a filtered list is not an access control. Anything that decides what a user can see has to be enforced again wherever they can act, because the second path does not go through the first.
This is also the one place a ForbiddenException is right rather than a 404 — the
project is one the contractor is entitled to know exists, and the message tells them how to fix it.
The 404-not-403 rule protects resources whose existence is sensitive, not every refusal.
Deriving what a user may do
A question that comes up as soon as there is a user interface: the client needs to know whether to show a "Cancel" button, and the rules are in the service.
The wrong answers are to reimplement them in the client, where the two copies drift, or to add an endpoint that asks "may I?" for each action, which is a round trip per button.
The version that holds up is to return the answer alongside the resource — a project carrying
the set of transitions this caller may make, computed from the same
TRANSITIONS table the write path uses. The client renders what it is told, and there is
still exactly one definition of the rule.
The thing to keep straight is that this is presentation, not enforcement. Whatever the response says, the write endpoint checks again — because the response is a hint the client may ignore, and a client is not a security boundary.
Soft deletes and authorization
if (!user || user.deleted) {
await bcrypt.compare(dto.password, DUMMY_HASH)
throw invalid
}Users here are soft-deleted, because quotes and reviews reference them forever and a hard delete would either cascade away a contractor's whole history or fail on a foreign key.
That creates an authorization obligation that is easy to miss: every path that grants access has to check the flag. Login does, above. A token issued before the deletion is the harder case — it stays valid until it expires, which is the snapshot problem again, and one of the concrete reasons short-lived tokens are worth the machinery.
Testing authorization
Authorization is the area where a passing test is least likely to mean what you think, because both a correct implementation and a broken one produce a rejection.
The habit that helps is testing the positive case alongside every negative one. "Nina cannot quote outside her trades" passes just as happily when the guard rejects everybody, so it is only meaningful next to "Luis can quote in his".
The second habit is asserting the status code rather than only the failure. A 403 where the design says 404 is a real leak, and a test that accepts any 4xx will never notice it.
Beyond roles
Roles are coarse. When you find yourself writing @Roles(A, B, C) on most routes, the
model has outgrown them and permissions are the next step — the same
SetMetadata plus Reflector mechanism carrying
@RequirePermissions('project:cancel'), with roles as named bundles of permissions.
Nest also documents CASL for attribute-based rules where the answer depends on the subject and the
object together.
What does not change is the split this lesson opened with. Whatever the vocabulary, checks that need a row belong in the service, and 404-not-403 stays the rule for anything with a guessable id.
Next: file upload, where the thing you must not trust is the file itself.