Guards

Control access to routes based on conditions like authentication and roles.

Guards determine whether a request should be handled by the route handler. They are the recommended way to implement authentication and authorization in MiiaJS.

CanActivate interface

A guard is a class that implements the CanActivate interface:

import type { CanActivate, RequestContext } from '@miiajs/core'

class AuthGuard implements CanActivate {
  canActivate(ctx: RequestContext): boolean | Promise<boolean> {
    const token = ctx.req.headers.get('authorization')
    return !!token
  }
}
  • Return true to allow the request
  • Return false to reject with 403 Forbidden
  • Throw an HttpException for a custom error response

Applying guards

Per route

import { UseGuard } from '@miiajs/core'

@Controller('/items')
class ItemController {
  @Delete('/:id')
  @UseGuard(AuthGuard)
  remove(ctx: RequestContext) {
    return { deleted: true }
  }
}

Per controller

@Controller('/admin')
@UseGuard(AuthGuard)
class AdminController {
  @Get('/dashboard')
  dashboard() {
    return { stats: {} }
  }
}

Global

const app = new Miia()
  .useGuard(AuthGuard)
  .register(AppModule)

Guards with DI

Guards can use dependency injection:

@Injectable()
class AuthGuard implements CanActivate {
  private tokenService = inject(TokenService)

  async canActivate(ctx: RequestContext): Promise<boolean> {
    const header = ctx.req.headers.get('authorization')
    if (!header?.startsWith('Bearer ')) {
      throw new UnauthorizedException()
    }

    const user = await this.tokenService.verify(header.slice(7))
    if (!user) throw new UnauthorizedException()

    ctx.user = user
    return true
  }
}

Parameterized guards

Create guard factories for reusable logic:

function Roles(...roles: string[]) {
  class RolesGuard implements CanActivate {
    canActivate(ctx: RequestContext) {
      return roles.includes(ctx.user?.role)
    }
  }
  return RolesGuard
}

Combine multiple guards:

@Delete('/:id')
@UseGuard(AuthGuard, Roles('admin'))
remove(ctx: RequestContext) {
  return { deleted: true }
}

Guards execute in order. If AuthGuard fails, Roles is never called.

Skipping guards

Use @SkipGuard() to exclude specific guards at compile time. The guard middleware is not added to the route pipeline at all - zero runtime overhead:

import { SkipGuard } from '@miiajs/core'

@Controller('/posts')
@UseGuard(AuthGuard())
class PostController {
  @Get('/')
  @SkipGuard(AuthGuard)  // AuthGuard is not applied to this route
  list() { return [] }

  @Get('/:id')            // AuthGuard applies normally
  findOne(ctx: RequestContext) { ... }
}

@SkipGuard works with direct guard classes, factory guards (like AuthGuard()), and global guards registered via app.useGuard(). Multiple guards can be skipped: @SkipGuard(GuardA, GuardB).

@Injectable()
class GlobalAuth implements CanActivate {
  canActivate(_ctx: RequestContext) { return false }
}

const app = new Miia().useGuard(GlobalAuth).register(AppModule)

@Controller('/webhooks')
class WebhookController {
  @Post('/stripe')
  @SkipGuard(GlobalAuth) // bypass the app-level guard for this endpoint
  stripe(ctx: RequestContext) {
    return { received: true }
  }
}
Global guards do not run on unmatched (404) routes - there is no resource to authorize, and this avoids leaking information about which auth scheme protects a path that doesn't exist. Global middleware (app.use()) still runs on 404s.

Declaring rejection codes

A guard rejects with whatever status its logic produces - 401 from an auth check, 403 from a role check, 429 from a rate limiter. Nothing can infer that from canActivate(), so the guard class states it with the GUARD_RESPONSES symbol from @miiajs/core:

import { GUARD_RESPONSES, UnauthorizedException } from '@miiajs/core'
import type { CanActivate, RequestContext } from '@miiajs/core'

class JwtAuthGuard implements CanActivate {
  static [GUARD_RESPONSES] = [401]

  canActivate(ctx: RequestContext) {
    const header = ctx.req.headers.get('authorization')
    if (!header?.startsWith('Bearer ')) throw new UnauthorizedException()
    return true
  }
}

The marker is documentation-only: @miiajs/swagger reads it when it builds the OpenAPI document and adds those responses to every route the guard protects. It has no effect on the request pipeline, and a guard without the marker contributes no responses to the spec.

Each entry is a status code or an object with a description:

import {
  ForbiddenException,
  GUARD_RESPONSES,
  UnauthorizedException,
  type GuardResponseDeclaration,
} from '@miiajs/core'

class RolesGuard implements CanActivate {
  static [GUARD_RESPONSES]: GuardResponseDeclaration[] = [
    { status: 401, description: 'Not authenticated' },
    { status: 403, description: 'Role mismatch' },
  ]

  canActivate(ctx: RequestContext) {
    if (!ctx.user) throw new UnauthorizedException()
    if (ctx.user.role !== 'admin') throw new ForbiddenException()
    return true
  }
}

Guards shipped by MiiaJS packages already carry their codes: AuthGuard from @miiajs/auth declares 401, and the guards in @miiajs/rate-limit declare 429. Installing those packages is enough - you write GUARD_RESPONSES only on your own guards.

Codes are collected from all three layers on a route - global, controller, and method guards. @SkipGuard(...) removes a guard's codes along with the guard itself, and so does @SkipRateLimit(). An explicit @ApiResponse for the same status always wins over the guard's declaration.

The spec is serialized once inside onReady(). A guard registered with app.useGuard()afterapp.init() still runs at request time, but its responses will not appear in the document.

Guard execution order

Global guards (app.useGuard(), filtered by @SkipGuard)
  -> Controller guards (@UseGuard on class)
  -> Route guards (@UseGuard on method)
  -> Route handler