NestJS Multi-tenancy API Key Authorization

Multi-tenancy is a common architectural pattern in modern applications where a single instance of software serves multiple clients (tenants). Implementing proper authentication and authorization for such systems can be challenging, especially when dealing with API keys that need to be secure and tenant-specific.
In this article, I'll share a secure, production-ready NestJS solution for implementing multi-tenant API key authorization. We'll explore how to properly generate, store, and validate API keys while ensuring tenants can only access their own resources.
Project Setup
For this implementation, we'll use the following stack:
- NestJS as backend framework
- PostgreSQL as database
- Redis as caching layer
To simplify the setup, we'll use Docker Compose. If you're not familiar with Docker, check out our previous article to understand why it's a game-changer for modern software development.
Note: For production environments, never use
synchronize: true
in ORM config as it could lead to data loss. Instead, use migrations to manage database schema changes. We used this configuration because this is only an example application running in a development environment.
Data Model
Our application will have three main entities:
- Tenant: Represents a client or organization using our system
- API Key: Authentication credentials associated with a tenant
- Resource: Any data owned by a tenant that requires controlled access
This structure allows us to implement proper multi-tenancy where tenants can only access their own resources through their API keys.
Let's look at our ApiKey
entity:
@Entity() export class ApiKey { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column() valueLast4: string; @Column() hash: string; @Column({ nullable: true }) expiresAt?: Date; @Column({ default: true }) active: boolean; @ManyToOne(() => Tenant) @JoinColumn() tenant: Tenant; }
Notice the important security-related fields:
name
: For easier API key identificationvalueLast4
: The last 4 characters of the API key for UI display purposeshash
: We never store API keys in plain text, but instead store a secure hashexpiresAt
: API keys should regularly rotate for securityactive
: Allows tenants to revoke API keys when needed
API Key Generation and Storage
Secure API key management is critical. Here's how we generate and store API keys in ApiKeysService
:
private generateApiKeyValue(): string { return randomBytes(32).toString('hex'); } private hashApiKey(key: string): string { const salt = pbkdf2Sync( this.saltKey, createHash('sha256') .update(`${key.at(4)}${this.saltKey}${key.at(key.length - 4)}`) .digest('hex'), this.iterations, this.keyLength, 'sha256', ).toString('hex'); const hash = pbkdf2Sync( key, salt, this.iterations, this.keyLength, 'sha512', ).toString('hex'); return hash; } async create( tenantId: string, dto: CreateApiKeyDto, ): Promise<{ apiKey: ApiKey; rawValue: string }> { const tenant = await this.tenantsService.checkExists(tenantId); const rawValue = this.generateApiKeyValue(); const hash = this.hashApiKey(rawValue); const valueLast4 = rawValue.slice(-4); const apiKey = this.apiKeysRepository.create({ name: dto.name, expiresAt: dto.expiresAt, hash, valueLast4, tenant, }); const ttl = dto.expiresAt ? new Date(dto.expiresAt).getTime() - new Date().getTime() : 0; await Promise.all([ this.cacheManager.set(hash, tenantId, ttl), this.apiKeysRepository.save(apiKey), ]); return { apiKey, rawValue, }; }
Understanding API Key Security
Let's break down the key security aspects:
- Random key generation: We use Node.js's
crypto.randomBytes()
to generate cryptographically strong keys. - Double hashing with salt:
- We first create a salt using the API key's characters and a server-side salt key
- Then we hash the API key using this salt and a strong algorithm (SHA-512)
- This prevents rainbow table attacks and significantly slows down brute force attempts
- Redis caching:
- We store the hash-to-tenant mapping in Redis for faster lookups
- If an API key expires, we set the Redis TTL accordingly
- This approach greatly improves performance as we don't need to query the database for each API call
- During service startup (
onModuleInit
), we preload all active API keys into Redis using theloadActiveApiKeysToCache()
method. This ensures that valid keys are immediately available in cache without waiting for a first access, improving initial performance.
- Last 4 characters: We store the last 4 characters of the API key for UI display, making it easier for tenants to identify their keys without revealing the entire value.
- One-time display: The raw API key is only returned once when created. After that, it's impossible to retrieve again (even for administrators because keys are hashed in DB), following security best practices.
- Timing attacks consideration: While comparing hashes, there's a theoretical risk of timing attacks - where attackers measure how long comparisons take to infer secrets. However, in our system, key verification involves async operations like Redis lookups and is subject to network latency. These factors far outweigh the minuscule differences in string comparison time, rendering timing attacks impractical in this context.
Authorization Guard
In NestJS, guards are perfect for handling authorization logic. We use custom decorators to specify which type of API key is allowed for each controller or method. The MasterKeyAuth
decorator is used for administrative operations that should only be performed with a master API key, while the TenantKeyAuth
decorator is used for tenant-specific operations that should be executed with a tenant's API key.
export const MasterKeyAuth = () => SetMetadata(MASTER_KEY_AUTH, true); export const TenantKeyAuth = () => SetMetadata(TENANT_KEY_AUTH, true);
Our guard implementation checks these decorators and validates the API key accordingly:
@Injectable() export class AuthGuard implements CanActivate { private readonly logger = new Logger(AuthGuard.name); private readonly masterKey: string = process.env.MASTER_API_KEY || 'secret_master_api_key'; constructor( private readonly apiKeysService: ApiKeysService, private readonly reflector: Reflector, ) {} private isDecoratorPresent( context: ExecutionContext, decorator: string, ): boolean { return Boolean( this.reflector.getAllAndOverride(decorator, [ context.getHandler(), context.getClass(), ]) || false, ); } async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest<Request>(); const isMasterKeyAllowed = this.isDecoratorPresent(context, MASTER_KEY_AUTH); const isTenantKeyAllowed = this.isDecoratorPresent(context, TENANT_KEY_AUTH); const apiKey = request.headers['x-api-key']; if (!apiKey) { throw new UnauthorizedException('API key is required in the request headers'); } if (typeof apiKey !== 'string') { throw new BadRequestException('API key must be a string'); } if (isMasterKeyAllowed) { if (this.masterKey === apiKey) { this.logger.warn('Master key used for authentication'); return true; } throw new ForbiddenException('Invalid master API key'); } if (!isTenantKeyAllowed) { throw new NotImplementedException('Unexpected authentication method'); } const pathTenantId = request.params.tenantId; const apiKeyTenantId = await this.apiKeysService.getTenantIdByKeyValue(apiKey); if (!apiKeyTenantId) { throw new ForbiddenException('Invalid API key'); } if (apiKeyTenantId !== pathTenantId) { throw new ForbiddenException('You are not authorized to access this tenant'); } this.logger.debug(`API key is valid for tenant ${apiKeyTenantId}.`); return true; } }
Understanding the Auth Guard
The guard performs several vital functions:
- API key presence check: Ensures the
x-api-key
header exists in the request - Authentication type determination: Uses NestJS's reflector to check which authentication type is required
- Master key validation: For admin operations, it validates against the environment-configured master key
- Tenant key validation: For tenant operations, it:
- Gets the tenant ID from the URL params
- Uses the API key service to find which tenant the key belongs to
- Compares the URL tenant ID with the key's tenant ID to prevent cross-tenant access
This approach ensures that a tenant can only access their own resources, even if they somehow obtained a valid API key from another tenant.
Rate Limiting
To prevent brute force attacks, one can use the @nestjs/throttler
package implementing rate limiting :
@Module({ imports: [ // ... ThrottlerModule.forRoot({ throttlers: [ { ttl: 60000, limit: 60, // 1 request per second is allowed }, ], }), ], }) export class AppModule {}
While this provides basic protection, for production applications we recommend implementing additional rate limiting at the infrastructure level using services like CloudFlare or AWS WAF. These services can better handle distributed brute force attacks and DDoS attempts.
API Endpoints and Controllers
Our implementation follows REST API best practices with nested resources:
@MasterKeyAuth() @Controller('tenants') export class TenantsController {} @MasterKeyAuth() @Controller('tenants/:tenantId/api-keys') export class ApiKeysController {} @TenantKeyAuth() @Controller('tenants/:tenantId/resources') export class ResourcesController {}
This structure ensures that:
- Only admin users (with the master key) can manage tenants and API keys
- Tenant users (with tenant-specific API keys) can only access their own resources
- The URL structure follows a logical hierarchy that makes authorization simpler
Testing the Implementation
Let's walk through a testing flow to see how it all works together:
Step 1: Start the services with Docker Compose
docker-compose up
Step 2: Create a tenant (using master key)
curl -X POST http://localhost:3000/tenants \ -H "Content-Type: application/json" \ -H "x-api-key: secret_master_api_key" \ -d '{"name": "u11d"}'
Expected response:
{ "id": "8f4b3a2e-1c7d-48fa-9b5e-6e3f2d10c8a9", "name": "u11d" }
Step 3: Create an API key for the tenant (using master key)
curl -X POST http://localhost:3000/tenants/8f4b3a2e-1c7d-48fa-9b5e-6e3f2d10c8a9/api-keys \ -H "Content-Type: application/json" \ -H "x-api-key: secret_master_api_key" \ -d '{"name": "Production API Key", "expiresAt": "2025-12-31T23:59:59Z"}'
Expected response:
{ "id": "c5d2e1f0-9a8b-47c6-85d4-3f2e1d0c9b8a", "name": "Production API Key", "valueLast4": "a7f9", "expiresAt": "2025-12-31T23:59:59.000Z", "active": true, "rawValue": "3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3a7f9" }
Important: This is the only time you'll see the full API key (
rawValue
). Store it securely!
Step 4: Create a resource using the tenant API key
curl -X POST http://localhost:3000/tenants/8f4b3a2e-1c7d-48fa-9b5e-6e3f2d10c8a9/resources \ -H "Content-Type: application/json" \ -H "x-api-key: 3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3a7f9" \ -d '{"name": "Resource 1"}'
Expected response:
{ "id": "2e3f4a5b-6c7d-48e9-9f0a-1b2c3d4e5f6a", "name": "Resource 1" }
Step 5: Fetch tenant resources
curl -X GET http://localhost:3000/tenants/8f4b3a2e-1c7d-48fa-9b5e-6e3f2d10c8a9/resources \ -H "Content-Type: application/json" \ -H "x-api-key: 3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3a7f9"
Expected response:
[ { "id": "2e3f4a5b-6c7d-48e9-9f0a-1b2c3d4e5f6a", "name": "Resource 1" } ]
Step 6: Try to access resources of a different tenant
curl -X GET http://localhost:3000/tenants/different-tenant-id/resources \ -H "x-api-key: 3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3a7f9"
Expected response:
{ "statusCode": 403, "message": "You are not authorized to access this tenant", "error": "Forbidden" }
This test flow validates that our authorization system correctly enforces tenant isolation.
Bonus: Infrastructure-Level API Key Validation
For production environments, it's worth implementing an additional layer of API key validation at the infrastructure level. This approach involves maintaining an allowlist of valid API keys at the reverse proxy or CDN level, which pre-filters requests before they reach your application servers. Services like AWS API Gateway with usage plans, Cloudflare Workers with KV storage, or AWS ALB with Lambda authorizers can handle this validation, significantly reducing the attack surface and preventing invalid requests from consuming application resources.
Implementing this requires synchronizing your application's API key operations with the infrastructure service through API calls. When your NestJS application creates or revokes API keys, it must also update the infrastructure-level allowlist using the respective service APIs (such as AWS API Gateway's CreateApiKey
or Cloudflare's KV API). While this adds complexity, the security and performance benefits make it a worthwhile investment for production multi-tenant systems.
Conclusion
Implementing secure API key authorization for multi-tenant applications requires careful consideration of security best practices. In this article, we've covered:
- Secure API key generation and storage with proper cryptographic hashing
- Efficient validation using Redis caching
- NestJS guards for enforcing tenant isolation
- Rate limiting to prevent brute force attacks
- A clean REST API structure that aligns with authorization requirements
The complete code for this implementation is available in our public GitHub repository.
By adopting these patterns, you can build multi-tenant systems that are both secure and performant. The approach can be extended to more complex scenarios by adding role-based access control or more granular permissions within each tenant.