Michał Miler
Michał Miler
Senior Software Engineer

NestJS Multi-tenancy API Key Authorization

Jun 25, 202510 min read

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:

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:

  1. Tenant: Represents a client or organization using our system
  2. API Key: Authentication credentials associated with a tenant
  3. 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 identification
  • valueLast4: The last 4 characters of the API key for UI display purposes
  • hash: We never store API keys in plain text, but instead store a secure hash
  • expiresAt: API keys should regularly rotate for security
  • active: 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:

  1. Random key generation: We use Node.js's crypto.randomBytes() to generate cryptographically strong keys.
  2. 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
  3. 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 the loadActiveApiKeysToCache() method. This ensures that valid keys are immediately available in cache without waiting for a first access, improving initial performance.
  4. 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.
  5. 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.
  6. 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:

  1. API key presence check: Ensures the x-api-key header exists in the request
  2. Authentication type determination: Uses NestJS's reflector to check which authentication type is required
  3. Master key validation: For admin operations, it validates against the environment-configured master key
  4. 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:

  1. Only admin users (with the master key) can manage tenants and API keys
  2. Tenant users (with tenant-specific API keys) can only access their own resources
  3. 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:

  1. Secure API key generation and storage with proper cryptographic hashing
  2. Efficient validation using Redis caching
  3. NestJS guards for enforcing tenant isolation
  4. Rate limiting to prevent brute force attacks
  5. 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.

RELATED POSTS
Aleksy Bohdziul
Aleksy Bohdziul
Senior Software Engineer

🚀 Speed Up Your Next.js App: Optimizing S3 Images with Cloudflare Images

Jun 18, 20254 min read
Article image
Paweł Sobolewski
Paweł Sobolewski
Senior Software Engineer

SSG, ISR, SSR, CSR: which strategy should I use in my Next.js e-commerce platform?

Jun 04, 202513 min read
Article image
Paweł Sobolewski
Paweł Sobolewski
Senior Software Engineer

Why Next.js is one of the best solutions for E-commerce?

May 14, 20254 min read
Article image