Engineering Notes
Home/Backend/Frameworks/Authentication & Authorization at the Framework Level
Backend · Frameworks

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.

⚙️ Backend● Intermediate⏱ 9 min read▶ 2 interactive🛠️ 4 exercises🎤 5 interview Q&As

🎯 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
Part 1Guards and Middleware)

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:

text
Authentication -> resolves ONE identity from the request, early, before the handler runs
Authorization  -> checks that identity against what THIS specific route/resource requires

backend/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:

ts
// 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
});
ts
// 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:

ts
// 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):

ts
// 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

  1. 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.
  2. 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.
  3. Trusting req.user without confirming a guard actually ran. A handler that reads req.user on a route where the guard was accidentally omitted gets undefined and likely crashes or, worse, silently treats the request as some default identity.
  4. 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

text
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 repetition
Part 2Role/Permission Checks)

Part 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:

ts
// 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:

ts
@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:

ts
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:

text
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)
An auth guard resolves identity (401 on failure), a roles guard checks role or permission (403), and the service checks ownership of the specific resource (404)
An auth guard resolves identity (401 on failure), a roles guard checks role or permission (403), and the service checks ownership of the specific resource (404)

⚠️ Common Pitfalls

  1. 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.
  2. 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.
  3. An explosion of narrowly-named roles instead of permissions. orders_read_only_no_refund_admin as a role name is a sign the system actually needs permission-based checks, not more roles.
  4. Applying RolesGuard without AuthGuard running first. A roles check against an unauthenticated (or not-yet-resolved) req.user either crashes or silently passes with undefined behavior — the two guards have a required order.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why can't a route-level role guard prevent editing other teams' articles?
Object-level authorization.
2. Safer default for a new endpoint?
Secure by default.
3. Which status when the token is valid but the role is insufficient?
Authenticated, not authorized.
4. Where should complex permission rules live?
One place, testable.