Skip to content

Guards

Guards determine whether a request should be allowed to proceed to the route handler. They run after middleware but before the handler, making them the right place for authentication checks, role verification, and other access control logic.

Every guard implements the CanActivate interface with a single method:

import { CanActivate } from 'stratal/guards'
import { RouterContext } from 'stratal/router'
export class AuthGuard implements CanActivate {
canActivate(context: RouterContext): boolean | Promise<boolean> {
const token = context.header('Authorization')
return token !== undefined
}
}

Return true to let the request through or false to block it. When a guard returns false, Stratal responds with 403 Forbidden and the route handler never executes.

The method can be synchronous or asynchronous. Use async when you need to look up a token, query a database, or call an external service.

The @UseGuards decorator attaches guards to controllers or individual methods. It accepts one or more guard classes or guard instances.

Apply a guard to every route in a controller:

import { Controller, IController, UseGuards, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
import { AuthGuard } from './auth.guard'
@Controller('/api/orders')
@UseGuards(AuthGuard)
export class OrdersController implements IController {
@Route({ response: orderListSchema })
index(ctx: RouterContext) {
// AuthGuard must pass before this runs
return ctx.json({ orders: [] })
}
@Route({ params: z.object({ id: z.string() }), response: orderSchema })
show(ctx: RouterContext) {
// AuthGuard also runs before this
const id = ctx.param('id')
return ctx.json({ order: { id } })
}
}

Apply a guard to a single route method:

@Controller('/api/orders')
@UseGuards(AuthGuard)
export class OrdersController implements IController {
@Route({ response: orderListSchema })
index(ctx: RouterContext) {
// Only AuthGuard runs
return ctx.json({ orders: [] })
}
@UseGuards(AdminGuard)
@Route({ response: orderSchema })
destroy(ctx: RouterContext) {
// AuthGuard runs first, then AdminGuard
const id = ctx.param('id')
// ...
}
}

Controller-level guards always run first, followed by method-level guards. In the example above, destroy is protected by both AuthGuard and AdminGuard, while index only requires AuthGuard.

Guards run sequentially in the order they are listed. If any guard returns false, execution stops immediately and the remaining guards are skipped.

@UseGuards(AuthGuard, RateLimitGuard, PermissionGuard)
export class SecureController implements IController {
// Execution: AuthGuard -> RateLimitGuard -> PermissionGuard -> handler
// If AuthGuard returns false, RateLimitGuard and PermissionGuard never run
}

The full request lifecycle with guards looks like this:

Guard classes are resolved from the request-scoped DI container. This means you can inject services into your guards just like any other provider:

import { CanActivate } from 'stratal/guards'
import { RouterContext } from 'stratal/router'
import { Transient, inject, DI_TOKENS } from 'stratal/di'
import type { StratalEnv } from 'stratal'
@Transient()
export class ApiKeyGuard implements CanActivate {
constructor(
@inject(DI_TOKENS.CloudflareEnv) private readonly env: StratalEnv,
) {}
canActivate(context: RouterContext): boolean {
const apiKey = context.header('X-API-Key')
return apiKey === this.env.API_SECRET
}
}

Sometimes you need to configure a guard with options at decoration time. A guard factory is a function that returns a guard instance:

import { CanActivate } from 'stratal/guards'
import { RouterContext } from 'stratal/router'
function RoleGuard(...roles: string[]): CanActivate {
return {
canActivate(context: RouterContext): boolean {
const container = context.getContainer()
const authContext = container.resolve(AuthContext)
const userRoles = authContext.getRoles()
return roles.some((role) => userRoles.includes(role))
},
}
}

Use it with @UseGuards by calling the factory:

@Controller('/api/admin')
@UseGuards(RoleGuard('admin', 'super-admin'))
export class AdminController implements IController {
// Only users with 'admin' or 'super-admin' role can access these routes
}

Guard instances (objects with a canActivate method) are used directly, while guard classes are resolved from the DI container.

Here is a more complete authentication guard that validates a JWT and sets the auth context for downstream services:

import { CanActivate } from 'stratal/guards'
import { RouterContext } from 'stratal/router'
import { Transient, inject, DI_TOKENS } from 'stratal/di'
@Transient()
export class AuthGuard implements CanActivate {
constructor(
@inject(TokenService) private readonly tokenService: TokenService,
) {}
async canActivate(context: RouterContext): Promise<boolean> {
const header = context.header('Authorization')
if (!header?.startsWith('Bearer ')) {
return false
}
const token = header.slice(7)
const payload = await this.tokenService.verify(token)
if (!payload) {
return false
}
// Store the authenticated user in the request container
// so other services can access it
const container = context.getContainer()
container.registerValue(DI_TOKENS.AuthContext, {
userId: payload.sub,
roles: payload.roles,
})
return true
}
}

Services downstream can then inject DI_TOKENS.AuthContext to access the authenticated user without repeating the token logic.

Building on the authentication guard, a permission guard checks whether the authenticated user has the required scopes:

import { CanActivate } from 'stratal/guards'
import { RouterContext } from 'stratal/router'
function PermissionGuard(...requiredScopes: string[]): CanActivate {
return {
canActivate(context: RouterContext): boolean {
const container = context.getContainer()
const auth = container.resolve(DI_TOKENS.AuthContext)
if (!auth) {
return false
}
return requiredScopes.every((scope) => auth.scopes?.includes(scope))
},
}
}

Combine it with AuthGuard on a controller:

@Controller('/api/students')
@UseGuards(AuthGuard)
export class StudentsController implements IController {
@Route({ response: studentListSchema })
index(ctx: RouterContext) {
// Auth only, no specific permissions needed
}
@UseGuards(PermissionGuard('students:create'))
@Route({ body: createStudentSchema, response: studentSchema })
async create(ctx: RouterContext) {
// Requires authentication AND 'students:create' permission
}
@UseGuards(PermissionGuard('students:delete'))
@Route({ params: z.object({ id: z.string() }), response: studentSchema })
destroy(ctx: RouterContext) {
// Requires authentication AND 'students:delete' permission
}
}

You can stack as many guards as you need. They all must pass for the request to proceed:

@Controller('/api/billing')
@UseGuards(AuthGuard, ScopeGuard, RateLimitGuard)
export class BillingController implements IController {
@UseGuards(PermissionGuard('billing:write'))
@Route({ body: chargeSchema, response: chargeResponseSchema })
async create(ctx: RouterContext) {
// Must pass: AuthGuard, ScopeGuard, RateLimitGuard, PermissionGuard
}
}

When guards are present on a route, Stratal automatically adds security scheme metadata to the OpenAPI specification. You can also set security schemes explicitly using the security option on @Controller or @Route:

@Controller('/api/users', {
tags: ['Users'],
security: ['bearerAuth'],
})
export class UsersController implements IController {
// All routes show bearerAuth in the OpenAPI spec
}