Skip to main content
Version: 4.0.0-preview

Guards & Authorization

Guard system for authentication and authorization.

Overview

FeatureDescription
Built-in GuardsReady-to-use auth, role, permission guards
RBAC@RequireRoles() decorator
Permissions@RequirePermissions() decorator
ABACRequirePolicy() for complex rules
Ownership@RequireOwnership() for user resources
CompositioncombineGuards(), sequenceGuards()
ConditionalwhenGuard() for conditional execution
CachingRequest-scoped result caching
ExpressoTS v4 guard pipeline: HTTP request through AuthProvider, credential validation, permission checks, and cache to handler execution or 401/403 denial

Basic Usage

Authentication Guard

Require authentication for routes:

import { RequireAuthentication, Principal } from "@expressots/core";
import { controller, Get, principal } from "@expressots/adapter-express";

@controller("/api/users")
export class UserController {
@Get("/profile")
@RequireAuthentication()
getProfile(@principal() user: Principal) {
return { user: user.details };
}
}

Role-Based Access

Restrict access based on user roles:

import { RequireRoles } from "@expressots/core";
import { controller, Get } from "@expressots/adapter-express";

@controller("/api/admin")
export class AdminController {
@Get("/dashboard")
@RequireRoles("admin")
getDashboard() {
return { message: "Welcome, admin!" };
}

@Get("/super")
@RequireRoles("admin", "super-admin") // Any of these roles
getSuperDashboard() {
return { message: "Welcome, super admin!" };
}
}

Permission-Based Access

Control access with granular permissions:

import { RequirePermissions } from "@expressots/core";
import { controller, Get, Post, Delete, body, param } from "@expressots/adapter-express";

@controller("/api/documents")
export class DocumentController {
@Get("/")
@RequirePermissions("documents:read")
listDocuments() {
return this.documentService.findAll();
}

@Post("/")
@RequirePermissions("documents:write")
createDocument(@body() dto: CreateDocumentDto) {
return this.documentService.create(dto);
}

@Delete("/:id")
@RequirePermissions("documents:delete")
deleteDocument(@param("id") id: string) {
return this.documentService.delete(id);
}
}

Resource Ownership

Ensure users can only access their own resources:

import { RequireOwnership } from "@expressots/core";
import { controller, Get, Put, Delete, body, param } from "@expressots/adapter-express";

@controller("/api/posts")
export class PostController {
@Get("/:id")
@RequireOwnership("id") // Checks if user.id matches post.authorId
getPost(@param("id") id: string) {
return this.postService.findById(id);
}

@Put("/:id")
@RequireOwnership("id")
updatePost(@param("id") id: string, @body() dto: UpdatePostDto) {
return this.postService.update(id, dto);
}

@Delete("/:id")
@RequireOwnership("id")
deletePost(@param("id") id: string) {
return this.postService.delete(id);
}
}

Attribute-Based Access Control (ABAC)

Use RequirePolicy() for complex authorization rules:

import { UseGuards, RequireAuth, RequirePolicy } from "@expressots/core";
import { controller, Get, param } from "@expressots/adapter-express";

@controller("/api/resources")
export class ResourceController {
@Get("/:id")
@UseGuards(
RequireAuth(),
RequirePolicy((attrs) => {
// Complex authorization logic
const { user, resource } = attrs;

// Admin can access everything
if (user.roles.includes("admin")) {
return true;
}

// User can access their own resources
if (resource.owner === user.id) {
return true;
}

// Users can access public resources
if (resource.isPublic) {
return true;
}

return false;
})
)
getResource(@param("id") id: string) {
return this.resourceService.findById(id);
}
}

Guard Composition

Combining Guards

Require all guards to pass:

import { combineGuards, RequireAuth, RequireRole, RequirePermission } from "@expressots/core";

@Post("/critical")
@UseGuards(
combineGuards(
RequireAuth(),
RequireRole("admin"),
RequirePermission("critical:access")
)
)
criticalAction() {
return { message: "Critical action performed" };
}

Sequencing Guards

Execute guards in sequence with dependencies:

import { sequenceGuards } from "@expressots/core";

@Get("/data")
@UseGuards(
sequenceGuards(
AuthGuard, // First: authenticate
TenantGuard, // Second: verify tenant access
ResourceGuard // Third: verify resource access
)
)
getData() {}

Conditional Guards

Execute guards based on conditions:

import { whenGuard, RequireRole, RequireAuth } from "@expressots/core";

@Get("/data")
@Post("/data")
@UseGuards(
RequireAuth(),
// Only require admin role for POST requests
whenGuard(
ctx => ctx.request.method === "POST",
RequireRole("admin")
)
)
handleData() {}

Creating Custom Guards

Implement custom guards by implementing IGuard:

import { provide, inject, IGuard, GuardContext } from "@expressots/core";

@provide(CustomGuard)
export class CustomGuard implements IGuard {
constructor(@inject(PermissionService) private permissionService: PermissionService) {}

async canActivate(context: GuardContext): Promise<boolean> {
const { request, principal } = context;

// Custom authorization logic
const resourceId = request.params.id;
const user = principal.details as { id: string };
const hasAccess = await this.permissionService.checkAccess(
user.id,
resourceId
);

return hasAccess;
}
}

// Usage
@Get("/:id")
@UseGuards(CustomGuard)
getResource(@param("id") id: string) {}

Guard Decorator

Use @Guard() for auto-discovery:

import { Guard, IGuard, GuardContext } from "@expressots/core";

@Guard()
@provide(RateLimitGuard)
export class RateLimitGuard implements IGuard {
constructor(@inject(RateLimitService) private rateLimitService: RateLimitService) {}

async canActivate(context: GuardContext): Promise<boolean> {
const { request } = context;
const clientIp = request.ip;

return await this.rateLimitService.checkLimit(clientIp);
}
}

Guard Caching

Cache guard results for performance:

import { provide, inject, IGuard, IGuardCache, GuardContext, GuardResult } from "@expressots/core";

@provide(ExpensiveGuard)
export class ExpensiveGuard implements IGuard {
constructor(@inject("IGuardCache") private cache: IGuardCache) {}

async canActivate(context: GuardContext): Promise<GuardResult> {
const scope = context.getRequestId();
const cacheKey = `guard:${context.request.path}`;

// Check cache first
const cached = this.cache.get(scope, cacheKey);
if (cached !== null) {
return cached;
}

// Expensive check
const allowed = await this.expensivePermissionCheck(context);
const result = allowed ? GuardResult.allow() : GuardResult.deny();

// Cache for this request
this.cache.set(scope, cacheKey, result);

return result;
}

private async expensivePermissionCheck(context: GuardContext): Promise<boolean> {
// Complex logic...
return true;
}
}

Permission Hierarchy

Define permission inheritance:

import { setupAuthorizationForExpress } from "@expressots/adapter-express";

export class App extends AppExpress {
async configureServices(): Promise<void> {
setupAuthorizationForExpress(
this.container.Container,
{
permissionHierarchy: {
"super-admin": ["admin", "moderator", "user"],
"admin": ["moderator", "user"],
"moderator": ["user"],
},
},
this.Middleware,
MyAuthProvider
);
}
}

With this hierarchy:

  • super-admin has all permissions of admin, moderator, and user
  • admin has all permissions of moderator and user
  • moderator has all permissions of user

Auth Provider

Implement an AuthProvider to integrate with your authentication system. The provider resolves a Principal for each request via getUser(), and the Principal itself answers the authorization questions:

import { provide } from "@expressots/core";
import { AuthProvider, Principal } from "@expressots/adapter-express";
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

interface JwtClaims {
sub: string;
roles: Array<string>;
permissions: Array<string>;
}

@provide(MyAuthProvider)
export class MyAuthProvider implements AuthProvider {
async getUser(
req: Request,
res: Response,
next: NextFunction
): Promise<Principal> {
const token = req.headers.authorization?.replace("Bearer ", "");

let claims: JwtClaims | null = null;
if (token) {
try {
claims = jwt.verify(token, process.env.JWT_SECRET!) as JwtClaims;
} catch {
claims = null;
}
}

return {
details: claims,
isAuthenticated: async () => claims !== null,
isInRole: async (role: string) =>
claims?.roles.includes(role) ?? false,
isResourceOwner: async (resourceId: unknown) => {
// Look up ownership in your data layer
return claims?.sub === String(resourceId);
},
};
}
}

Setup

Configure the authorization system in your application:

import { setupAuthorizationForExpress } from "@expressots/adapter-express";
import { MyAuthProvider } from "./auth/my-auth-provider";

export class App extends AppExpress {
async configureServices(): Promise<void> {
setupAuthorizationForExpress(
this.container.Container,
{
enablePreloading: true,
enableCaching: true,
permissionHierarchy: {
"super-admin": ["admin", "moderator", "user"],
"admin": ["moderator", "user"],
"moderator": ["user"],
},
},
this.Middleware,
MyAuthProvider
);
}
}

Testing Guards

Unit Testing Guards

Test guards in isolation:

import { CustomGuard } from "./custom.guard";
import { GuardContext } from "@expressots/core";

describe("CustomGuard", () => {
let guard: CustomGuard;
let mockPermissionService: jest.Mocked<PermissionService>;

beforeEach(() => {
mockPermissionService = {
checkAccess: jest.fn(),
} as any;

guard = new CustomGuard(mockPermissionService);
});

it("should allow access when user has permission", async () => {
mockPermissionService.checkAccess.mockResolvedValue(true);

const context = {
request: { params: { id: "123" } },
principal: { details: { id: "user-1" } },
} as unknown as GuardContext;

const result = await guard.canActivate(context);

expect(result).toBe(true);
expect(mockPermissionService.checkAccess).toHaveBeenCalledWith(
"user-1",
"123"
);
});

it("should deny access when user lacks permission", async () => {
mockPermissionService.checkAccess.mockResolvedValue(false);

const context = {
request: { params: { id: "123" } },
principal: { details: { id: "user-1" } },
} as unknown as GuardContext;

const result = await guard.canActivate(context);

expect(result).toBe(false);
});
});

Testing Built-in Guards

Test @RequireRoles() and @RequirePermissions():

import { RequireRole, RequirePermission } from "@expressots/core";
import { GuardContext } from "@expressots/core";

describe("Built-in Guards", () => {
describe("RequireRole", () => {
it("should allow access for users with required role", async () => {
const guard = RequireRole("admin");

const context = {
principal: {
isAuthenticated: async () => true,
isInRole: async (role: string) => role === "admin",
details: { roles: ["admin"] }
},
request: {},
} as unknown as GuardContext;

// Built-in guards return a GuardResult
const result = await guard.canActivate(context);
expect(result.allowed).toBe(true);
});

it("should deny access for users without required role", async () => {
const guard = RequireRole("admin");

const context = {
principal: {
isAuthenticated: async () => true,
isInRole: async () => false,
details: { roles: ["user"] }
},
request: {},
} as unknown as GuardContext;

const result = await guard.canActivate(context);
expect(result.allowed).toBe(false);
});
});

describe("RequirePermission", () => {
it("should allow access for users with required permission", async () => {
const guard = RequirePermission("documents:read");

// PermissionGuard falls back to principal.hasPermission()
// when no SecurityContext is bound
const context = {
principal: {
hasPermission: async (perm: string) => perm === "documents:read"
},
request: {},
} as unknown as GuardContext;

const result = await guard.canActivate(context);
expect(result.allowed).toBe(true);
});
});
});

Testing Guard Composition

Test combined guards:

import { combineGuards, RequireAuth, RequireRole, GuardContext, GuardResult } from "@expressots/core";

describe("Guard Composition", () => {
it("should require all guards to pass", async () => {
const combined = combineGuards(
RequireAuth(),
RequireRole("admin")
);

// User is authenticated but not admin
const context = {
principal: {
isAuthenticated: async () => true,
isInRole: async () => false,
details: { roles: ["user"] }
},
request: {},
} as unknown as GuardContext;

const result = (await combined.canActivate(context)) as GuardResult;
expect(result.allowed).toBe(false);
});

it("should pass when all guards pass", async () => {
const combined = combineGuards(
RequireAuth(),
RequireRole("admin")
);

const context = {
principal: {
isAuthenticated: async () => true,
isInRole: async (role: string) => role === "admin",
details: { roles: ["admin"] }
},
request: {},
} as unknown as GuardContext;

const result = (await combined.canActivate(context)) as GuardResult;
expect(result.allowed).toBe(true);
});
});

Testing Auth Provider

Test your custom AuthProvider:

import { MyAuthProvider } from "./my-auth-provider";
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

jest.mock("jsonwebtoken");

describe("MyAuthProvider", () => {
let provider: MyAuthProvider;
const res = {} as Response;
const next = jest.fn() as NextFunction;

beforeEach(() => {
jest.resetAllMocks();
provider = new MyAuthProvider();
});

it("should resolve an authenticated principal for a valid token", async () => {
(jwt.verify as jest.Mock).mockReturnValue({
sub: "user-123",
roles: ["user"],
permissions: ["read"],
});

const request = {
headers: { authorization: "Bearer valid-token" },
} as Request;

const principal = await provider.getUser(request, res, next);

expect(await principal.isAuthenticated()).toBe(true);
expect(await principal.isInRole("user")).toBe(true);
expect(principal.details).toMatchObject({ sub: "user-123" });
});

it("should resolve an unauthenticated principal for an invalid token", async () => {
(jwt.verify as jest.Mock).mockImplementation(() => {
throw new Error("Invalid token");
});

const request = {
headers: { authorization: "Bearer invalid-token" },
} as Request;

const principal = await provider.getUser(request, res, next);

expect(await principal.isAuthenticated()).toBe(false);
});

it("should resolve an unauthenticated principal when no token provided", async () => {
const request = {
headers: {},
} as Request;

const principal = await provider.getUser(request, res, next);

expect(await principal.isAuthenticated()).toBe(false);
});
});

E2E Testing with Guards

Test guards in a real application:

import { bootstrap } from "@expressots/core";
import { App } from "./app";

describe("Guards E2E", () => {
let app: Awaited<ReturnType<typeof bootstrap>>;
let port: number;

beforeAll(async () => {
app = await bootstrap(App, { port: 0 });
port = await app.getPort();
});

afterAll(async () => {
const httpServer = await app.getHttpServer();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
});

it("should block unauthenticated requests", async () => {
const response = await fetch(`http://localhost:${port}/api/users/profile`);

expect(response.status).toBe(401);
});

it("should allow authenticated requests", async () => {
const token = await getTestToken({ roles: ["user"] });

const response = await fetch(
`http://localhost:${port}/api/users/profile`,
{
headers: { Authorization: `Bearer ${token}` },
}
);

expect(response.status).toBe(200);
});

it("should block requests without required role", async () => {
const token = await getTestToken({ roles: ["user"] });

const response = await fetch(
`http://localhost:${port}/api/admin/dashboard`,
{
headers: { Authorization: `Bearer ${token}` },
}
);

expect(response.status).toBe(403);
});

it("should allow requests with required role", async () => {
const token = await getTestToken({ roles: ["admin"] });

const response = await fetch(
`http://localhost:${port}/api/admin/dashboard`,
{
headers: { Authorization: `Bearer ${token}` },
}
);

expect(response.status).toBe(200);
});

it("should enforce resource ownership", async () => {
const token = await getTestToken({ userId: "user-1" });

// Try to access another user's post
const response = await fetch(
`http://localhost:${port}/api/posts/user-2-post`,
{
headers: { Authorization: `Bearer ${token}` },
}
);

expect(response.status).toBe(403);
});
});

async function getTestToken(payload: any): Promise<string> {
// Generate a test JWT token
return jwt.sign(payload, process.env.JWT_SECRET);
}

Real-World RBAC Examples

Multi-Tenant Application

@controller("/api/tenants/:tenantId")
export class TenantController {
@Get("/data")
@UseGuards(
RequireAuth(),
combineGuards(
TenantAccessGuard, // Verify user belongs to tenant
RequireRole("admin", "member")
)
)
getTenantData(@param("tenantId") tenantId: string, @principal() user: Principal) {
return this.tenantService.getData(tenantId);
}

@Post("/invite")
@UseGuards(
RequireAuth(),
TenantAccessGuard,
RequireRole("admin", "owner") // Only admins/owners can invite
)
inviteUser(@param("tenantId") tenantId: string, @body() dto: InviteDto) {
return this.tenantService.inviteUser(tenantId, dto);
}
}

Content Management System

@controller("/api/content")
export class ContentController {
@Get("/")
@UseGuards(RequirePermission("content:read"))
listContent() {
return this.contentService.findAll();
}

@Post("/")
@UseGuards(
combineGuards(
RequireAuth(),
RequirePermission("content:create")
)
)
createContent(@body() dto: CreateContentDto) {
return this.contentService.create(dto);
}

@Put("/:id")
@UseGuards(
RequireAuth(),
// Admins need the update permission
whenGuard(
(ctx) => ctx.principal.isInRole("admin"),
RequirePermission("content:update")
),
// Non-admins must own the content
whenGuard(
async (ctx) => !(await ctx.principal.isInRole("admin")),
RequireResourceOwner("id")
)
)
updateContent(@param("id") id: string, @body() dto: UpdateContentDto) {
return this.contentService.update(id, dto);
}

@Delete("/:id")
@UseGuards(
combineGuards(
RequireAuth(),
RequirePermission("content:delete"),
// Non-admins must also own the content
whenGuard(
async (ctx) => !(await ctx.principal.isInRole("admin")),
RequireResourceOwner("id")
)
)
)
deleteContent(@param("id") id: string) {
return this.contentService.delete(id);
}
}

Healthcare Application (HIPAA Compliant)

@controller("/api/patients")
export class PatientController {
@Get("/:id")
@UseGuards(
RequireAuth(),
combineGuards(
RequireRole("doctor", "nurse", "admin"),
RequirePermission("patients:read"),
PatientAccessGuard // Verify provider can access this patient
)
)
getPatient(@param("id") id: string) {
return this.patientService.findById(id);
}

@Post("/:id/prescriptions")
@UseGuards(
RequireAuth(),
combineGuards(
RequireRole("doctor"), // Only doctors can prescribe
RequirePermission("prescriptions:write"),
PatientAccessGuard,
LicenseValidGuard // Verify doctor's license is valid
)
)
createPrescription(@param("id") patientId: string, @body() dto: PrescriptionDto) {
return this.prescriptionService.create(patientId, dto);
}
}

Security Best Practices

  1. Always Authenticate First: Use RequireAuth() before other guards
  2. Principle of Least Privilege: Grant minimum permissions needed
  3. Use Permission Hierarchy: Define role inheritance to reduce configuration
  4. Log Authorization Failures: Monitor failed authorization attempts
  5. Cache Carefully: Be cautious when caching authorization results
  6. Test Edge Cases: Test boundary conditions and permission combinations
  7. Use HTTPS: Always use HTTPS in production to protect tokens
  8. Rotate Secrets: Regularly rotate JWT secrets and API keys
  9. Implement Rate Limiting: Prevent brute force attacks on protected endpoints
  10. Audit Access: Log all access to sensitive resources

Common Security Patterns

Two-Factor Authentication

@Post("/sensitive-action")
@UseGuards(
RequireAuth(),
RequireTwoFactor(), // Custom guard to verify 2FA
RequireRole("admin")
)
performSensitiveAction() {
return { success: true };
}

IP Whitelisting

@Post("/admin/config")
@UseGuards(
RequireAuth(),
RequireRole("admin"),
IPWhitelistGuard // Only allow from specific IPs
)
updateConfig(@body() config: ConfigDto) {
return this.configService.update(config);
}

Time-Based Access

@Get("/reports")
@UseGuards(
RequireAuth(),
RequireRole("analyst"),
BusinessHoursGuard // Only allow during business hours
)
getReports() {
return this.reportService.generate();
}

Best Practices

  1. Use Built-in Guards: Start with built-in guards before creating custom ones
  2. Combine Guards: Use combineGuards() for multiple requirements
  3. Use Conditions: Use whenGuard() for conditional authorization
  4. Cache Results: Use IGuardCache for expensive checks
  5. Define Hierarchy: Set up permission hierarchy to reduce configuration
  6. Implement AuthProvider: Integrate with your auth system properly
  7. Test Thoroughly: Test all authorization paths and edge cases
  8. Monitor Access: Log and monitor authorization failures
  9. Use Granular Permissions: Prefer fine-grained permissions over broad roles
  10. Document Permissions: Maintain clear documentation of all permissions

Comparison with Other Frameworks

FeatureExpressoTSNestJSSpring Boot
Built-in Guards✅ 5+ guards⚠️ Basic guards✅ Via Security
Guard CompositioncombineGuards()❌ Manual❌ Manual
Conditional GuardswhenGuard()❌ Not available❌ Not available
Guard CachingIGuardCache❌ Not available❌ Not available
Permission Hierarchy✅ Built-in❌ Manual✅ Via Security
ABAC SupportRequirePolicy()❌ Manual⚠️ SpEL expressions

Support the Project

ExpressoTS is MIT-licensed open source. See the support guide to contribute.