Guards and role-based access
Authentication tells you who is asking. Authorisation decides what they may do, and it is where the expensive bugs live — because a missing check does not throw an error, it just quietly allows something.
A guard
A guard returns true to allow a request and throws or returns false to reject it. From module 5, it runs before pipes.
// apps/api/src/auth/guards/jwt-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
That is the whole implementation — the strategy from the last lesson does the work.
@Get("me")
@UseGuards(JwtAuthGuard)
me(@Req() req: Request) {
return req.user;
}
Anything without a valid token gets 401 and never reaches the method.
Reading the user tidily
// apps/api/src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): AuthUser =>
ctx.switchToHttp().getRequest().user,
);
@Get("me")
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return user;
}
Typed, and no @Req() anywhere in your controllers.
@CurrentUser() without a guard gives undefined. The guard is what
populates it. That combination — a handler reading user.id on a route nobody
guarded — is a real source of bugs, and the next section removes it.
Protect by default
Listing @UseGuards on every route means one day somebody forgets, and a new
endpoint is public without anyone noticing.
Invert it:
// apps/api/src/app.module.ts
@Module({
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}
Now everything requires authentication, and you opt out explicitly:
// apps/api/src/auth/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return isPublic ? true : super.canActivate(context);
}
}
@Public()
@Get()
findAll() { ... }
Forgetting a decorator now fails closed, with a 401 you notice immediately, rather than leaving an endpoint open. That asymmetry is the entire argument: the failure mode of forgetting should be inconvenient, not dangerous.
getAllAndOverride checks the method first and then the class, so you can mark
a whole controller public.
Roles
export const ROLES_KEY = "roles";
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) throw new UnauthorizedException();
if (!required.includes(user.role)) {
throw new ForbiddenException("You do not have permission to do this");
}
return true;
}
}
@Controller("admin/products")
@Roles(Role.ADMIN)
export class AdminProductsController { ... }
401 when there is no user, 403 when there is one without permission. The distinction from module 1: "I do not know you" versus "I know you and no".
Register it globally after the auth guard — order matters, since roles need
user to already be set:
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
]
Ownership is not a role
This is the check people miss.
@Get(":id")
@UseGuards(JwtAuthGuard)
findOne(@Param("id") id: string) {
return this.orders.findOne(id); // whose order?
}
Authenticated, and any logged-in customer can read anyone's order by changing the id. A guard checking roles does not help — the caller has the right role.
Insecure direct object reference, and it is one of the most common real vulnerabilities. The fix is to scope the query:
@Get(":id")
findOne(@Param("id") id: string, @CurrentUser() user: AuthUser) {
return this.orders.findOneForUser(id, user.id);
}
async findOneForUser(id: string, userId: string) {
const order = await this.prisma.order.findFirst({
where: { id, userId },
});
if (!order) throw new NotFoundException("Order not found");
return order;
}
Two things worth copying.
The ownership check is in the where clause, not an if afterwards.
Fetching and then comparing works and is easier to forget; putting it in the
query means the row is simply not found.
Return 404, not 403. Telling somebody "that exists but is not yours" confirms an order with that id exists. 404 reveals nothing.
The rule: a user id in a query must come from the token, never from the
request. If a caller can supply it, they can supply somebody else's — which is
also why /cart has no id in it, from module 7.
Where the check belongs
In the service, with the data — not in the controller.
A controller check only protects that route. A service check protects every caller, including a future admin screen or a scheduled job that you have not written yet.
The pattern that scales: methods that take a user id and cannot be called
without one. findOneForUser(id, userId) is harder to misuse than findOne(id)
plus a separate check.
An admin viewing anything
async findOneFor(id: string, user: AuthUser) {
const order = await this.prisma.order.findFirst({
where: user.role === Role.ADMIN ? { id } : { id, userId: user.id },
});
if (!order) throw new NotFoundException("Order not found");
return order;
}
One place decides, and the rule is visible. Two separate methods — one admin,
one customer — is also fine and arguably clearer. What is not fine is a boolean
skipOwnershipCheck parameter, because somewhere it will be passed true by
accident.
Testing it
Authorisation is the thing most worth testing, because a missing check is silent.
describe("GET /api/orders/:id", () => {
it("returns the order to its owner", async () => {
await request(app.getHttpServer())
.get(`/api/orders/${order.id}`)
.set("Cookie", ownerCookie)
.expect(200);
});
it("404s for a different customer", async () => {
await request(app.getHttpServer())
.get(`/api/orders/${order.id}`)
.set("Cookie", otherCustomerCookie)
.expect(404);
});
it("401s with no cookie", async () => {
await request(app.getHttpServer())
.get(`/api/orders/${order.id}`)
.expect(401);
});
it("403s when a customer calls an admin route", async () => {
await request(app.getHttpServer())
.patch(`/api/admin/products/${product.id}`)
.set("Cookie", customerCookie)
.expect(403);
});
});
Write the negative tests. The happy path is exercised constantly by using the application; the "somebody else's order" path is exercised only by an attacker, unless you test it.
Check your work
Why protect by default: forgetting a decorator then fails closed with a 401 you notice, instead of leaving an endpoint open.
Why @CurrentUser() can be undefined: the guard populates it, so an
unguarded route has no user.
401 versus 403: no valid identity, versus a valid identity without permission.
Why guard order matters: the roles guard needs user, which the auth guard
sets.
What an insecure direct object reference is: using an id from the request to fetch a record without checking it belongs to the caller.
Why the ownership check goes in the where clause: it cannot be forgotten,
and a non-matching row is simply not found.
Why 404 rather than 403 for someone else's order: 403 confirms the record exists.
Where authorisation belongs: the service, so every caller is protected, not only one route.
Practice
- Add
JwtAuthGuardto one route. Call it with and without a cookie. - Register it globally and add
@Public()to the product endpoints. Confirm a new route is protected by default. - Add
@CurrentUser()to an unguarded route and observeundefined. - Add
RolesGuardand an admin-only route. Call it as a customer and confirm 403. - Register the guards in the wrong order and observe the failure.
- Build
GET /orders/:idwithout an ownership check. Log in as two customers and read the other's order. - Fix it in the
whereclause. Confirm you get 404, not 403, and explain why. - Move the check from the controller into the service and note what that protects that the controller version did not.
- Write all four authorisation tests above and run them.
- Add a new endpoint and deliberately forget every decorator. Confirm it is protected rather than open.
Next: protecting pages and actions on the front end.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship