Skip to main content

Access Control

The @Authorized decorator enforces authentication and role requirements on routes.

Basic Usage

Require Authentication

Use @Authorized() without arguments to require any authenticated user:

@Controller('/dashboard')
class DashboardController {
  @Authorized()
  @Get('/')
  index() {
    return { stats: [] };
  }
}

If no user is set, returns 401 Unauthorized:

{
  "code": "UNAUTHORIZED",
  "message": "Authentication required"
}

Require Specific Roles

Pass role names to restrict access:

@Controller('/admin')
class AdminController {
  @Authorized(['admin'])
  @Get('/users')
  listUsers() {
    return { users: [] };
  }
}

If the user lacks required roles, returns 403 Forbidden:

{
  "code": "FORBIDDEN",
  "message": "Insufficient permissions"
}

Role Matching

OR Logic (Any Role)

By default, access is granted if the user has any of the specified roles:

@Controller('/admin')
class AdminController {
  @Authorized(['admin', 'moderator'])
  @Delete('/posts/:id')
  removePost() {
    // User needs 'admin' OR 'moderator'
  }
}

AND Logic (All Roles)

For AND logic, use multiple @Authorized decorators:

@Controller('/content')
class ContentController {
  @Authorized(['verified'])
  @Authorized(['premium'])
  @Get('/exclusive-content')
  exclusiveContent() {
    // User needs 'verified' AND 'premium'
  }
}

Or check in the handler:

@Controller('/content')
class ContentController {
  @Authorized()
  @Get('/exclusive-content')
  exclusiveContent(roles = currentRoles()) {
    if (!roles.includes('verified') || !roles.includes('premium')) {
      throw new HTTPException(403, { message: 'Premium verified users only' });
    }
    return { content: '...' };
  }
}

Decorator Placement

Method Level

Apply to specific routes:

@Controller('/posts')
class PostController {
  @Get('/')
  list() {
    // Public — no auth required
  }

  @Authorized()
  @Post('/')
  create() {
    // Requires authentication
  }

  @Authorized(['admin'])
  @Delete('/:id')
  delete() {
    // Requires admin role
  }
}

With Other Decorators

@Authorized works with other method decorators:

@Controller('/api')
class ApiController {
  @Authorized()
  @RateLimit({ limit: 100, windowSec: 60, key: 'posts' })
  @Post('/posts')
  create(data = validated(CreatePostSchema)) {
    return { created: true };
  }
}

Error Responses

StatusCodeCondition
401UNAUTHORIZEDNo user set (not authenticated)
403FORBIDDENUser lacks required roles

Customizing Error Messages

Handle authorization errors in your error handler:

const app = createApp([http({
    controllers: [DashboardController, AdminController],
    // @ts-expect-error shorthand error handler example
    onError: (error: Error, c: RequestContext) => {
      if (error instanceof HTTPException) {
        if (error.status === 401) {
          return c.json({
            error: 'Please log in to continue',
            loginUrl: '/auth/login',
          }, 401);
        }
        if (error.status === 403) {
          return c.json({
            error: 'You do not have permission to access this resource',
            requiredRoles: error.message,
          }, 403);
        }
      }
      throw error;
    },
  })]);

Common Patterns

Public Routes with Optional Auth

Don't use @Authorized — check the user manually:

@Controller('/posts')
class PostController {
  constructor(private postRepo = inject(PostRepository)) {}

  @Get('/:id')
  async getPost(id = pathParam('id')) {
    const user = currentUser() as User | undefined;
    const post = await this.postRepo.findById(id);

    return {
      ...post,
      canEdit: user?.id === post.authorId,
    };
  }
}

Owner-Only Access

Combine @Authorized with ownership checks:

@Controller('/posts')
class PostController {
  constructor(private postRepo = inject(PostRepository)) {}

  @Authorized()
  @Put('/:id')
  async updatePost(id = pathParam('id'), data = validated(UpdateSchema)) {
    const user = currentUser() as User;
    const post = await this.postRepo.findById(id);

    if (post.authorId !== user.id && !currentRoles().includes('admin')) {
      throw new HTTPException(403, { message: 'Not your post' });
    }

    return this.postRepo.update(id, data);
  }
}

Role Hierarchy

Check for any role in a hierarchy:

const isEditor = (roles: readonly string[]) =>
  roles.some(r => ['admin', 'editor'].includes(r));

@Controller('/posts')
class PostController {
  @Authorized()
  @Put('/:id')
  updatePost(roles = currentRoles()) {
    if (!isEditor(roles)) {
      throw new HTTPException(403, { message: 'Editors only' });
    }
    // ...
  }
}

Resource-Scoped Authorization

For complex scenarios, move logic to a service:

@Injectable()
class PostAuthorizationService {
  canView(post: Post): boolean {
    if (post.isPublic) return true;
    const user = currentUser() as User | undefined;
    return user?.id === post.authorId;
  }

  canEdit(post: Post): boolean {
    const user = currentUser() as User | undefined;
    const roles = currentRoles();
    if (roles.includes('admin')) return true;
    return user?.id === post.authorId;
  }

  canDelete(): boolean {
    const roles = currentRoles();
    return roles.includes('admin');
  }
}

@Controller('/posts')
class PostController {
  constructor(
    private postRepo = inject(PostRepository),
    private authService = inject(PostAuthorizationService)
  ) {}

  @Authorized()
  @Delete('/:id')
  async delete(id = pathParam('id')) {
    const post = await this.postRepo.findById(id);

    if (!this.authService.canDelete()) {
      throw new HTTPException(403, { message: 'Cannot delete this post' });
    }

    await this.postRepo.delete(id);
    return { deleted: true };
  }
}

Testing Protected Routes

Without Authentication

it('returns 401 for unauthenticated requests', async () => {
  const res = await readyApp.http.request('/dashboard');
  
  expect(res.status).toBe(401);
});

With Authentication

Use a middleware to inject the user within request context — setUser() must be called during request handling, not in test setup:

it('returns data for authenticated users', async () => {
  const res = await readyApp.http.request('/dashboard', { headers: { 'X-Test-User': 'true' } });
  expect(res.status).toBe(200);
});

Testing Role Requirements

it('returns 403 for non-admin users', async () => {
  const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'user' } });
  expect(res.status).toBe(403);
});

it('allows admin access', async () => {
  const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'admin' } });
  expect(res.status).toBe(200);
});

Best Practices

  1. Use @Authorized() for protected routes — Don't manually check currentUser() for basic auth requirements

  2. Keep role checks coarse — Use @Authorized for feature-level access, services for resource-level logic

  3. Fail closed — When in doubt, deny access; it's easier to grant than revoke

  4. Log authorization failures — Track failed access attempts for security monitoring

  5. Test both paths — Always test authenticated and unauthenticated scenarios