Authentication & Authorization at the Framework Level
Frameworks give you hooks that run before handlers. Watch guards reject unauthenticated and unauthorized requests, then go beyond role checks to object-level and permission-based authorization in the service layer.
🎯 What you'll learn
- Two questions, two mechanisms
- Guards/middleware before the handler; extracting the current user
- Protecting a controller vs a single route
- Roles, object-level checks and permissions
📚 Before you start
This topic assumes familiarity with backend/frameworks/06-middleware (the mechanism this topic builds on directly) and backend/frameworks/09-dependency_injection. It does not re-teach token mechanics — security/security/02-jwt and security/security/03-oauth cover what a JWT is and how OAuth2 flows work in depth. This topic is about how a framework enforces those tokens at the route level: where in the request chain identity gets resolved, and how a route gets protected.
1Two Separate Questions, Two Separate Mechanisms
"Who is this caller" (authentication) and "may this caller do this" (authorization) are resolved at different points and by different framework constructs, even though both commonly get bundled under one guard/middleware in simple cases:
Authentication -> resolves ONE identity from the request, early, before the handler runs
Authorization -> checks that identity against what THIS specific route/resource requiresbackend/api-design/10-api_security covers this same authentication/authorization split from the API-design and attack-surface angle (BOLA, mass assignment); this topic covers the framework mechanics that make the check actually happen before a handler ever executes.
2Middleware/Guards: Intercepting Before the Handler Runs
The framework mechanism for "run this check before the route handler, and block the request if it fails" is the same middleware chain covered generally in backend/frameworks/06-middleware, specialized here for auth:
// Express: an auth middleware function
async function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "missing token" });
try {
req.user = await verifyJwt(token); // attaches the resolved identity to the request
next(); // only calls the actual handler if verification succeeded
} catch {
res.status(401).json({ error: "invalid or expired token" });
}
}
app.get("/orders", requireAuth, (req, res) => {
res.json(orderService.listForUser(req.user.id)); // req.user is guaranteed to exist here
});// NestJS: a Guard plays the identical role, integrated with the DI container
@Injectable()
class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return false; // NestJS turns a `false` return into a 403 automatically
req.user = verifyJwtSync(token); // simplified for illustration
return true;
}
}
@UseGuards(AuthGuard)
@Controller("orders")
class OrderController { /* every route on this controller now requires auth */ }Both examples do the same job: run before the handler, resolve identity from the token, attach it somewhere the handler can read it, and short-circuit the request (401/403) if resolution fails — the handler body itself never has to think about token verification at all.
3Extracting the Authenticated User Inside a Handler
Once middleware/a guard has attached the resolved identity to the request, handlers access it directly rather than re-parsing the token themselves:
// Express
app.get("/me", requireAuth, (req, res) => {
res.json({ id: req.user.id, email: req.user.email }); // req.user was set by requireAuth
});
// NestJS: a parameter decorator is the idiomatic way to pull it out
@Get("me")
getProfile(@CurrentUser() user: User) { // @CurrentUser reads req.user, set by AuthGuard
return { id: user.id, email: user.email };
}This is the practical payoff of resolving authentication once, centrally, rather than re-verifying a token inside every handler: every downstream handler trusts that if it was reached at all, req.user is already populated and valid.
4Protecting a Whole Controller vs. a Single Route
Guards/middleware can apply at different granularities — globally (every route in the app), per-controller (every route on one controller), or per-route (one specific handler):
// Global: every route in the entire app requires auth
app.use(requireAuth);
// Per-controller: every route on OrderController requires auth, nothing else does
@UseGuards(AuthGuard)
@Controller("orders")
class OrderController {}
// Per-route: only THIS one handler requires auth
@Controller("public")
class PublicController {
@Get("health")
health() { return { ok: true }; } // no guard — publicly accessible
@UseGuards(AuthGuard)
@Get("admin-stats")
adminStats() { /* ... */ } // guarded individually
}The right granularity is whichever minimizes repetition for the actual shape of the API — an app that's almost entirely authenticated except for a login/health-check endpoint usually applies auth globally and explicitly excludes the few public routes, rather than decorating every protected route individually.
⚠️ Common Pitfalls
- Re-verifying the token inside individual handlers instead of trusting the guard/middleware. Defeats the point of centralizing authentication — every handler ends up with duplicated, easy-to-get-wrong verification logic.
- Forgetting to apply the guard to a new route added later. Per-route guards require remembering to add them every time; a controller-level or global guard with explicit opt-outs for public routes is generally safer against this exact mistake.
- Trusting
req.userwithout confirming a guard actually ran. A handler that readsreq.useron a route where the guard was accidentally omitted getsundefinedand likely crashes or, worse, silently treats the request as some default identity. - Conflating "guard returned true" with full authorization. A guard resolving who the caller is says nothing about whether they're allowed to touch this specific resource — that's a separate, per-operation check (part 2 of this topic).
📋 Quick Reference
Authentication -> resolved ONCE, by middleware/a guard, before the handler runs
Middleware/Guard -> intercepts the request, verifies the token, attaches identity, or blocks (401/403)
Handler -> trusts identity is already resolved; never re-verifies the token itself
Granularity -> global, per-controller, or per-route — pick whichever minimizes repetitionPart 1 covered resolving who the caller is, centrally, via middleware/guards. Part 2 covers the separate, per-operation question of what that caller is allowed to do — role-based and permission-based authorization enforced at the route or resource level.
1Role-Based Authorization: Checking Against a Coarse Category
The simplest authorization model assigns each user one or more roles, and routes declare which roles may access them:
// NestJS: a decorator declares required roles, a guard reads and enforces it
const Roles = (...roles: string[]) => SetMetadata("roles", roles);
@Injectable()
class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>("roles", context.getHandler());
if (!requiredRoles) return true; // no @Roles(...) declared — no restriction
const req = context.switchToHttp().getRequest();
return requiredRoles.some((role) => req.user.roles.includes(role));
}
}
@UseGuards(AuthGuard, RolesGuard) // AuthGuard resolves identity FIRST, RolesGuard checks it SECOND
@Roles("admin")
@Delete("orders/:id")
deleteOrder(@Param("id") id: string) { /* only reached if req.user has the "admin" role */ }Notice the two guards run in sequence, each with a single job: AuthGuard (part 1) resolves who; RolesGuard checks what they're allowed to do, and only after identity is already known. Collapsing both into one guard is possible for simple apps, but keeping them separate means a role check can be applied selectively (only some authenticated routes need it) without duplicating the identity-resolution logic.
2Why Role Checks Aren't Enough: Object-Level Authorization
A role check answers "is this user an admin" — a coarse, resource-independent question. It does not answer "may this specific customer see this specific order," which requires comparing the request against the actual resource being accessed, not just the caller's role:
@UseGuards(AuthGuard)
@Get("orders/:id")
async getOrder(@Param("id") id: string, @CurrentUser() user: User) {
const order = await orderService.findById(id);
// A role check alone can't catch this — it needs the ACTUAL order's ownership,
// which only exists once the resource itself has been fetched.
if (order.customerId !== user.id && !user.roles.includes("admin")) {
throw new ForbiddenException();
}
return order;
}This is precisely the BOLA vulnerability covered from the API-design angle in backend/api-design/10-api_security — a guard running before the handler can check roles, but it generally cannot check resource ownership without first loading the resource, which is why this specific check commonly lives inside the handler (or a dedicated authorization service called from within it) rather than in a route-level guard.
3Permission-Based Authorization: Finer-Grained Than Roles
Roles work well when access maps cleanly onto a small number of broad categories ("admin", "customer"). Real systems often need finer granularity — a specific permission rather than a whole role:
interface User {
id: number;
permissions: string[]; // e.g. ["orders:read", "orders:refund", "users:read"]
}
function requirePermission(permission: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user.permissions.includes(permission)) {
return res.status(403).json({ error: `missing permission: ${permission}` });
}
next();
};
}
app.post("/orders/:id/refund", requireAuth, requirePermission("orders:refund"), refundHandler);Permissions decouple "what a user can do" from "what role they're labeled with" — a support agent role and a billing-admin role might both carry orders:refund without needing to share every other permission, which a pure role check can't express without either an explosion of role names or overly broad role definitions.
4Where Guards/Middleware Hand Off to the Service Layer
Framework-level guards are well-suited to coarse, request-shape-only checks (is there a valid token, does this role/permission exist on the user) that don't require touching the database. Object-level checks that need the actual resource — is this order this user's — are business logic and belong in the service layer (backend/frameworks/10-services_and_repositories), not stuffed into a guard that would otherwise need to duplicate a repository lookup just to run an authorization check:
Guard/middleware -> "does this token exist, is it valid, does this role/permission exist" (cheap, request-only)
Service layer -> "does THIS user own THIS specific resource" (needs the actual data)⚠️ Common Pitfalls
- Treating a passing role check as full authorization. A role check is necessary but not sufficient the moment resource ownership matters — see section 2's BOLA connection.
- Duplicating a full resource lookup inside a guard just to check ownership. This either duplicates the repository call the handler/service will make anyway, or forces the guard to depend on repositories it shouldn't need to know about — object-level checks belong past the guard, inside the handler or a dedicated authorization service.
- An explosion of narrowly-named roles instead of permissions.
orders_read_only_no_refund_adminas a role name is a sign the system actually needs permission-based checks, not more roles. - Applying
RolesGuardwithoutAuthGuardrunning first. A roles check against an unauthenticated (or not-yet-resolved)req.usereither crashes or silently passes with undefined behavior — the two guards have a required order.
📋 Quick Reference
Role check -> coarse, request-only: "is this user an admin" — a guard's job
Permission check -> finer-grained than a role, same guard-level mechanism
Object-level check -> "does THIS user own THIS resource" — needs real data, belongs in the service layer
Guard ordering -> AuthGuard (who) MUST run before RolesGuard (what they're allowed)🧠 Key takeaways
- Authentication runs first (guard/middleware validates credentials and sets
req.user). - Role checks at route level are coarse; object-level checks need the loaded resource (service layer).
- Permission/policy libraries (CASL, Casbin, OPA) express fine-grained rules in one place.
- Apply guards globally with explicit
@Public()opt-outs, so new routes are protected by default.
🎤 Interview Q&A
Q1How do you implement authentication in a backend framework?core▾
Use the framework's auth middleware with proven libraries: validate JWTs from an IdP (JWKS), or cookie sessions; Passport strategies in Node/NestJS, ASP.NET Core authentication handlers, FastAPI security dependencies. Avoid rolling your own crypto or password storage.
- Where is the user stored after authentication? On the request context (
req.user,HttpContext.User).
Q2How do you implement authorization?core▾
Role checks for coarse access, policy-based authorization for rules (ASP.NET Core policies, NestJS guards), and resource-based checks in services (does this user own this order?). Deny by default.
- Why are resource checks in services? Only the service knows the resource's owner/tenant.
Q3How do you secure endpoints by default?senior▾
Apply a global authentication requirement and explicitly mark public endpoints ([AllowAnonymous], @Public()), so a forgotten attribute doesn't expose an endpoint.
- Test for this? An automated test listing all endpoints and their auth requirements.
Q4How do you propagate user identity to downstream services?core▾
Forward the user's access token (or exchange it via token exchange for a downstream audience), or use service identity plus a signed user context. Each service still validates tokens.
- What's token exchange? OAuth RFC 8693: swapping a token for one scoped to another service.
Q5How do you handle multi-tenancy in authorization?core▾
Include tenant ID in the token/claims, enforce tenant scoping in every query (global query filters in EF Core, row-level security in Postgres), and test cross-tenant access explicitly.
- Risk? One missed filter leaks another tenant's data.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.