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
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED | No user set (not authenticated) |
| 403 | FORBIDDEN | User 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
it('returns data for authenticated users', async () => {
// Set up authentication context
setUser({ id: '123', name: 'Test' }, ['user']);
const res = await readyApp.http.request('/dashboard');
expect(res.status).toBe(200);
});
Testing Role Requirements
it('returns 403 for non-admin users', async () => {
setUser({ id: '123', name: 'Test' }, ['user']); // Not admin
const res = await readyApp.http.request('/admin/users');
expect(res.status).toBe(403);
});
it('allows admin access', async () => {
setUser({ id: '123', name: 'Test' }, ['admin']);
const res = await readyApp.http.request('/admin/users');
expect(res.status).toBe(200);
});
Best Practices
-
Use
@Authorized()for protected routes — Don't manually checkcurrentUser()for basic auth requirements -
Keep role checks coarse — Use
@Authorizedfor feature-level access, services for resource-level logic -
Fail closed — When in doubt, deny access; it's easier to grant than revoke
-
Log authorization failures — Track failed access attempts for security monitoring
-
Test both paths — Always test authenticated and unauthenticated scenarios