Skip to main content

Overview

tsoa provides a flexible authentication system through the @Security decorator. You can implement various authentication strategies including API keys, JWT, OAuth2, and custom schemes.
Authentication handlers are defined in your tsoa configuration and invoked automatically before secured endpoints are executed.

Basic Authentication

Securing Endpoints

Use the @Security decorator to protect endpoints:
import { Route, Get, Security } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Get()
  @Security('api_key')
  public async getUsers(): Promise<User[]> {
    return await userService.getAll();
  }

  @Get('{userId}')
  @Security('api_key')
  public async getUser(userId: number): Promise<User> {
    return await userService.getById(userId);
  }
}

Controller-Level Security

Apply security to all endpoints in a controller:
import { Route, Get, Post, Security } from '@tsoa/runtime';

@Route('admin')
@Security('jwt')  // Applies to all methods
export class AdminController {
  @Get('users')
  public async getAllUsers(): Promise<User[]> {
    return await userService.getAll();
  }

  @Post('users')
  public async createUser(@Body() user: User): Promise<User> {
    return await userService.create(user);
  }
}

Disabling Security

Override controller-level security for specific endpoints:
import { Route, Get, Post, Security, NoSecurity } from '@tsoa/runtime';

@Route('api')
@Security('jwt')  // Default for all methods
export class ApiController {
  @Get('protected')
  public async getProtected(): Promise<Data> {
    // Requires JWT authentication
    return await dataService.getProtected();
  }

  @Get('public')
  @NoSecurity()  // Override: no authentication required
  public async getPublic(): Promise<Data> {
    return await dataService.getPublic();
  }
}

Authentication Handler

Define authentication logic in your tsoa configuration:

Express Example

// authentication.ts
import { Request } from 'express';

export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  if (securityName === 'api_key') {
    const apiKey = request.headers['x-api-key'];
    
    if (!apiKey) {
      throw new Error('No API key provided');
    }
    
    const isValid = await validateApiKey(apiKey as string);
    
    if (!isValid) {
      throw new Error('Invalid API key');
    }
    
    return { apiKey };
  }

  if (securityName === 'jwt') {
    const token = request.headers['authorization']?.replace('Bearer ', '');
    
    if (!token) {
      throw new Error('No token provided');
    }
    
    try {
      const decoded = await verifyJwt(token);
      
      // Check scopes if required
      if (scopes) {
        for (const scope of scopes) {
          if (!decoded.scopes.includes(scope)) {
            throw new Error(`Missing required scope: ${scope}`);
          }
        }
      }
      
      return decoded;
    } catch (error) {
      throw new Error('Invalid token');
    }
  }

  throw new Error('Unknown security scheme');
}

Koa Example

// authentication.ts
import { Context } from 'koa';

export async function koaAuthentication(
  context: Context,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  if (securityName === 'api_key') {
    const apiKey = context.headers['x-api-key'];
    
    if (!apiKey) {
      context.throw(401, 'No API key provided');
    }
    
    const isValid = await validateApiKey(apiKey as string);
    
    if (!isValid) {
      context.throw(401, 'Invalid API key');
    }
    
    return { apiKey };
  }

  if (securityName === 'jwt') {
    const token = context.headers['authorization']?.replace('Bearer ', '');
    
    if (!token) {
      context.throw(401, 'No token provided');
    }
    
    try {
      const decoded = await verifyJwt(token);
      
      if (scopes) {
        for (const scope of scopes) {
          if (!decoded.scopes.includes(scope)) {
            context.throw(403, `Missing required scope: ${scope}`);
          }
        }
      }
      
      return decoded;
    } catch (error) {
      context.throw(401, 'Invalid token');
    }
  }

  context.throw(500, 'Unknown security scheme');
}

API Key Authentication

Implement API key authentication:
// models/auth.ts
export interface ApiKeyInfo {
  apiKey: string;
  userId: number;
  permissions: string[];
}

// authentication.ts
export async function expressAuthentication(
  request: Request,
  securityName: string
): Promise<ApiKeyInfo> {
  if (securityName === 'api_key') {
    const apiKey = 
      request.headers['x-api-key'] || 
      request.query.api_key;
    
    if (!apiKey) {
      throw new Error('API key required');
    }
    
    // Validate against database
    const keyInfo = await db.apiKeys.findOne({ key: apiKey });
    
    if (!keyInfo || !keyInfo.isActive) {
      throw new Error('Invalid or inactive API key');
    }
    
    // Check rate limits
    const rateLimitOk = await checkRateLimit(keyInfo.userId);
    if (!rateLimitOk) {
      throw new Error('Rate limit exceeded');
    }
    
    return {
      apiKey: keyInfo.key,
      userId: keyInfo.userId,
      permissions: keyInfo.permissions
    };
  }
  
  throw new Error('Unknown security scheme');
}

// controller.ts
@Route('data')
export class DataController {
  @Get()
  @Security('api_key')
  public async getData(
    @Request() request: RequestWithUser
  ): Promise<Data[]> {
    // Access authenticated user info
    const user = request.user;
    return await dataService.getForUser(user.userId);
  }
}

JWT Authentication

Implement JWT bearer token authentication:
import jwt from 'jsonwebtoken';

// models/auth.ts
export interface JwtPayload {
  userId: number;
  email: string;
  roles: string[];
  scopes: string[];
}

// authentication.ts
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<JwtPayload> {
  if (securityName === 'jwt') {
    const authHeader = request.headers['authorization'];
    
    if (!authHeader) {
      throw new Error('Authorization header required');
    }
    
    const [scheme, token] = authHeader.split(' ');
    
    if (scheme !== 'Bearer') {
      throw new Error('Invalid authentication scheme');
    }
    
    if (!token) {
      throw new Error('Token required');
    }
    
    try {
      const decoded = jwt.verify(
        token,
        process.env.JWT_SECRET!
      ) as JwtPayload;
      
      // Validate token hasn't been revoked
      const isRevoked = await db.revokedTokens.exists({ token });
      if (isRevoked) {
        throw new Error('Token has been revoked');
      }
      
      // Check required scopes
      if (scopes) {
        const hasAllScopes = scopes.every(scope => 
          decoded.scopes.includes(scope)
        );
        
        if (!hasAllScopes) {
          throw new Error('Insufficient permissions');
        }
      }
      
      return decoded;
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        throw new Error('Token expired');
      }
      if (error instanceof jwt.JsonWebTokenError) {
        throw new Error('Invalid token');
      }
      throw error;
    }
  }
  
  throw new Error('Unknown security scheme');
}

OAuth2 with Scopes

Implement OAuth2 authentication with scope-based authorization:
import { Route, Get, Post, Delete, Security } from '@tsoa/runtime';

@Route('posts')
export class PostController {
  @Get()
  @Security('oauth2', ['read:posts'])
  public async getPosts(): Promise<Post[]> {
    return await postService.getAll();
  }

  @Post()
  @Security('oauth2', ['write:posts'])
  public async createPost(@Body() post: Post): Promise<Post> {
    return await postService.create(post);
  }

  @Delete('{postId}')
  @Security('oauth2', ['write:posts', 'delete:posts'])
  public async deletePost(postId: number): Promise<void> {
    await postService.delete(postId);
  }
}

// authentication.ts
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  if (securityName === 'oauth2') {
    const token = request.headers['authorization']?.replace('Bearer ', '');
    
    if (!token) {
      throw new Error('Token required');
    }
    
    // Validate with OAuth2 provider
    const tokenInfo = await validateOAuth2Token(token);
    
    if (!tokenInfo.active) {
      throw new Error('Invalid token');
    }
    
    // Check scopes
    if (scopes) {
      const hasAllScopes = scopes.every(scope => 
        tokenInfo.scopes.includes(scope)
      );
      
      if (!hasAllScopes) {
        throw new Error(
          `Missing required scopes: ${scopes.join(', ')}`
        );
      }
    }
    
    return tokenInfo;
  }
  
  throw new Error('Unknown security scheme');
}

Multiple Authentication Schemes

OR Authentication

Allow multiple authentication methods (any one succeeds):
import { Route, Get, Security } from '@tsoa/runtime';

@Route('data')
export class DataController {
  // Accepts either JWT or API key
  @Get()
  @Security('jwt')
  @Security('api_key')
  public async getData(): Promise<Data[]> {
    return await dataService.getAll();
  }
}
The authentication handler will try each scheme in order:
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  if (securityName === 'jwt') {
    // Try JWT authentication
    const token = request.headers['authorization']?.replace('Bearer ', '');
    if (token) {
      try {
        return await verifyJwt(token);
      } catch (error) {
        // JWT failed, will try next scheme
        throw error;
      }
    }
    throw new Error('No JWT token provided');
  }
  
  if (securityName === 'api_key') {
    // Try API key authentication
    const apiKey = request.headers['x-api-key'];
    if (apiKey) {
      const isValid = await validateApiKey(apiKey as string);
      if (isValid) {
        return { apiKey };
      }
    }
    throw new Error('Invalid API key');
  }
  
  throw new Error('Unknown security scheme');
}

AND Authentication

Require multiple authentication methods (all must succeed):
import { Route, Get, Security } from '@tsoa/runtime';

@Route('admin')
export class AdminController {
  // Requires BOTH JWT and API key
  @Get('sensitive')
  @Security({
    jwt: ['admin'],
    api_key: []
  })
  public async getSensitiveData(): Promise<Data> {
    return await dataService.getSensitive();
  }
}

Accessing User Information

Access authenticated user data in your controllers:
import { Request as ExpressRequest } from 'express';

interface RequestWithUser extends ExpressRequest {
  user?: {
    userId: number;
    email: string;
    roles: string[];
  };
}

@Route('profile')
export class ProfileController {
  @Get()
  @Security('jwt')
  public async getProfile(
    @Request() request: RequestWithUser
  ): Promise<UserProfile> {
    const userId = request.user!.userId;
    return await userService.getProfile(userId);
  }

  @Put()
  @Security('jwt')
  public async updateProfile(
    @Request() request: RequestWithUser,
    @Body() updates: ProfileUpdates
  ): Promise<UserProfile> {
    const userId = request.user!.userId;
    return await userService.updateProfile(userId, updates);
  }
}

Role-Based Access Control

Implement role-based authorization:
// authentication.ts
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  if (securityName === 'jwt') {
    const token = request.headers['authorization']?.replace('Bearer ', '');
    
    if (!token) {
      throw new Error('Token required');
    }
    
    const decoded = await verifyJwt(token);
    
    // Check roles (scopes used for roles)
    if (scopes && scopes.length > 0) {
      const hasRequiredRole = scopes.some(role => 
        decoded.roles.includes(role)
      );
      
      if (!hasRequiredRole) {
        throw new Error(
          `Access denied. Required role: ${scopes.join(' or ')}`
        );
      }
    }
    
    // Attach user to request
    request.user = decoded;
    
    return decoded;
  }
  
  throw new Error('Unknown security scheme');
}

// controllers/admin.ts
@Route('admin')
export class AdminController {
  @Get('users')
  @Security('jwt', ['admin'])
  public async getAllUsers(): Promise<User[]> {
    return await userService.getAll();
  }

  @Post('users')
  @Security('jwt', ['admin', 'super_admin'])
  public async createUser(@Body() user: User): Promise<User> {
    return await userService.create(user);
  }

  @Delete('users/{userId}')
  @Security('jwt', ['super_admin'])
  public async deleteUser(userId: number): Promise<void> {
    await userService.delete(userId);
  }
}

// controllers/moderator.ts
@Route('posts')
export class PostModerationController {
  @Post('{postId}/approve')
  @Security('jwt', ['admin', 'moderator'])
  public async approvePost(postId: number): Promise<Post> {
    return await postService.approve(postId);
  }

  @Delete('{postId}')
  @Security('jwt', ['admin', 'moderator'])
  public async deletePost(postId: number): Promise<void> {
    await postService.delete(postId);
  }
}

Security Configuration

Define security schemes in your tsoa.json:
{
  "spec": {
    "securityDefinitions": {
      "api_key": {
        "type": "apiKey",
        "name": "x-api-key",
        "in": "header"
      },
      "jwt": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      },
      "oauth2": {
        "type": "oauth2",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://example.com/oauth/authorize",
            "tokenUrl": "https://example.com/oauth/token",
            "scopes": {
              "read:posts": "Read posts",
              "write:posts": "Create and update posts",
              "delete:posts": "Delete posts",
              "admin": "Administrative access"
            }
          }
        }
      },
      "basic": {
        "type": "http",
        "scheme": "basic"
      }
    }
  }
}

Error Handling

Handle authentication errors properly:
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  try {
    if (securityName === 'jwt') {
      const token = request.headers['authorization']?.replace('Bearer ', '');
      
      if (!token) {
        const error: any = new Error('No token provided');
        error.status = 401;
        throw error;
      }
      
      const decoded = await verifyJwt(token);
      
      if (scopes) {
        const hasScopes = scopes.every(scope => 
          decoded.scopes.includes(scope)
        );
        
        if (!hasScopes) {
          const error: any = new Error('Insufficient permissions');
          error.status = 403;
          throw error;
        }
      }
      
      return decoded;
    }
    
    const error: any = new Error('Unknown security scheme');
    error.status = 500;
    throw error;
  } catch (error: any) {
    // Ensure errors have proper status codes
    if (!error.status) {
      error.status = 401;
    }
    throw error;
  }
}

Best Practices

Always use HTTPS to protect authentication credentials:
// Configure your server to enforce HTTPS
app.use((req, res, next) => {
  if (req.secure || process.env.NODE_ENV !== 'production') {
    next();
  } else {
    res.redirect(`https://${req.headers.host}${req.url}`);
  }
});
Protect authentication endpoints from brute force:
import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: 'Too many authentication attempts'
});

app.use('/auth/login', authLimiter);
Always validate tokens completely:
export async function verifyJwt(token: string): Promise<JwtPayload> {
  // Verify signature
  const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
  
  // Check expiration (usually done by jwt.verify, but explicit is good)
  if (decoded.exp && decoded.exp < Date.now() / 1000) {
    throw new Error('Token expired');
  }
  
  // Check if token is revoked
  const isRevoked = await tokenBlacklist.has(token);
  if (isRevoked) {
    throw new Error('Token revoked');
  }
  
  // Validate user still exists and is active
  const user = await db.users.findOne({ id: decoded.userId });
  if (!user || !user.isActive) {
    throw new Error('User not found or inactive');
  }
  
  return decoded;
}
Return correct HTTP status codes:
// 401 Unauthorized: Missing or invalid credentials
if (!token) {
  const error: any = new Error('Authentication required');
  error.status = 401;
  throw error;
}

// 403 Forbidden: Valid credentials but insufficient permissions
if (!hasRequiredScope) {
  const error: any = new Error('Insufficient permissions');
  error.status = 403;
  throw error;
}
Track authentication for security monitoring:
export async function expressAuthentication(
  request: Request,
  securityName: string,
  scopes?: string[]
): Promise<any> {
  const ip = request.ip;
  const userAgent = request.headers['user-agent'];
  
  try {
    const user = await authenticateUser(request, securityName);
    
    // Log successful authentication
    logger.info('Authentication successful', {
      userId: user.id,
      securityScheme: securityName,
      ip,
      userAgent
    });
    
    return user;
  } catch (error) {
    // Log failed authentication
    logger.warn('Authentication failed', {
      securityScheme: securityName,
      error: error.message,
      ip,
      userAgent
    });
    
    throw error;
  }
}

Next Steps

Responses

Handle authentication errors and responses

Controllers

Learn more about securing controllers

Validation

Validate authenticated requests

Deployment

Deploy your secured API

Build docs developers (and LLMs) love