How to Implement API Key Authentication in NestJS for Your SaaS

JWT authentication solves the "who is this user" problem. It works when your own customers log into your SaaS application through a browser or mobile app. But SaaS platforms have another authentication problem that JWT does not solve well — the one API key authentication exists to solve: how do your customers' servers authenticate to your API?
Your customers want to write scripts that call your API from CI/CD pipelines. They want to integrate your platform into their internal tools. They want to build custom dashboards that pull data from your service. None of these involve a browser login flow, and none of them should require a user to be sitting at a keyboard entering credentials.
That is the use case for API key authentication. This post walks through a complete NestJS API key authentication SaaS implementation — generating cryptographically secure keys, hashing them before storage, scoping them by permission level, rotating them without breaking integrations, rate limiting per key, and building a management UI your customers can use.

Why API Key Authentication Instead of OAuth or JWT
API keys exist for a different threat model than user authentication. When a user logs into your SaaS through the browser, they interact with a login form, see a consent screen, and can revoke access through a settings page. When a server uses an API key, there is no browser, no consent screen, and no user in the loop.
API keys are simpler than OAuth2 for machine-to-machine communication. OAuth2's client credentials grant works, but it requires a token endpoint, access token expiration, and refresh logic on the client side. An API key is a static secret that the client sends with every request. It does not expire (unless you explicitly expire it), and the client does not need to implement token refresh logic.
API keys are more durable than JWTs for long-running integrations. A JWT expires in 15 minutes. A background job that runs for an hour would need to refresh its token midway through. An API key works for the entire duration of the job.
The tradeoff is revocation granularity. If a user's JWT is compromised, you invalidate their session. If an API key is compromised, you have to rotate the key — which means updating every integration that uses it. This is why API keys should be scoped to the minimum permissions needed, and why every key should have a clear owner and purpose.
Key Generation — Cryptographically Secure Random Keys
An API key is only as strong as its entropy. Generate keys using Node's crypto.randomBytes with at least 32 bytes (256 bits) of randomness.
1// src/api-keys/api-keys.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import { randomBytes } from 'node:crypto';
6import { ApiKey } from './entities/api-key.entity';
7
8@Injectable()
9export class ApiKeysService {
10 constructor(
11 @InjectRepository(ApiKey)
12 private readonly apiKeyRepo: Repository<ApiKey>,
13 ) {}
14
15 generateApiKey(): { rawKey: string; prefix: string } {
16 const prefix = 'sk_live_';
17 const entropy = randomBytes(32).toString('hex');
18 const rawKey = `${prefix}${entropy}`;
19 return { rawKey, prefix };
20 }
21}The prefix serves two purposes. It makes the key recognizable — when a customer sees sk_live_ in their configuration file, they know it is an API key for your production environment. It also lets you identify the key type without looking at the database, which is useful for key rotation and validation logic later.
The entropy portion is 64 hexadecimal characters representing 32 random bytes. Including the prefix, the full key is roughly 71 characters. This is enough entropy to make brute-force attacks impractical even at scale.
Show the full key exactly once — in the creation response. After that, only show the last four characters. Store the prefix and the hash in the database, never the raw key.

Hashing API Keys With bcrypt Before Storage
Never store API keys in plain text. If your database is compromised, every API key is exposed — and with the average data breach now costing $4.88M (IBM, 2024) and stolen credentials the initial action in 24% of breaches (Verizon DBIR 2024), a table full of plaintext keys is not a risk worth taking to save a hash. Hash them with bcrypt at the same cost factor you would use for passwords, following the same OWASP cryptographic storage guidance you would apply to user credentials.
1import * as bcrypt from 'bcrypt';
2
3async function hashApiKey(rawKey: string): Promise<string> {
4 const saltRounds = 10;
5 return bcrypt.hash(rawKey, saltRounds);
6}
7
8async function verifyApiKey(rawKey: string, hash: string): Promise<boolean> {
9 return bcrypt.compare(rawKey, hash);
10}Bcrypt with a cost factor of 10 takes roughly 10 hashes per second on a modern CPU. This means a brute-force attack against a bcrypt-hashed API key is computationally expensive even if the hash is exposed. The tradeoff is that every API request that presents a key needs to perform a bcrypt comparison. For high-traffic endpoints, this becomes a performance consideration — which is why you cache the result after the first successful verification.
Store only the hash and the last four characters of the key in the database:
1// Method on ApiKeysService
2async createApiKey(
3 name: string,
4 scope: string[],
5 tenantId: string,
6 createdBy: string,
7): Promise<{ id: string; rawKey: string; lastFour: string }> {
8 const { rawKey, prefix } = this.generateApiKey();
9 const hash = await hashApiKey(rawKey);
10 const lastFour = rawKey.slice(-4);
11
12 const apiKey = await this.apiKeyRepo.save({
13 name,
14 prefix,
15 hash,
16 lastFour,
17 scope,
18 tenantId,
19 createdBy,
20 active: true,
21 createdAt: new Date(),
22 });
23
24 return { id: apiKey.id, rawKey, lastFour };
25}The raw key is returned exactly once. After this response, the application can only look up the last four characters. This is the same approach Stripe, GitHub, and most API-first platforms use.
Database Schema for API Keys
The schema needs to support scopes, expiration, active status, and rotation tracking.
1CREATE TABLE api_keys (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 name TEXT NOT NULL, -- human-readable label, e.g. "CI/CD Pipeline"
4 prefix TEXT NOT NULL, -- 'sk_live_' or 'sk_test_'
5 hash TEXT NOT NULL, -- bcrypt hash
6 last_four TEXT NOT NULL, -- last 4 chars for UI display
7 scope TEXT[] NOT NULL DEFAULT '{}', -- ['read', 'write'] or ['billing:read', 'users:write']
8 active BOOLEAN NOT NULL DEFAULT true,
9 expires_at TIMESTAMPTZ, -- null = never expires
10 last_used_at TIMESTAMPTZ,
11 rotated_from_id UUID REFERENCES api_keys(id), -- for rotation tracking
12 rotated_at TIMESTAMPTZ,
13 created_by UUID NOT NULL REFERENCES users(id),
14 tenant_id UUID NOT NULL REFERENCES tenants(id),
15 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
16 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
17);
18
19CREATE INDEX idx_api_keys_prefix ON api_keys(prefix, active);
20CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id);The rotated_from_id and rotated_at fields support the dual-key rotation pattern. When a key is rotated, the old key keeps working for the grace period. The prefix index lets the guard quickly narrow the set of candidate hashes before performing the bcrypt comparison.

NestJS Guard That Validates API Keys Per Request
The guard extracts the API key from the Authorization header, looks up candidates by prefix, performs the bcrypt comparison, and attaches the authenticated client to the request.
1// src/api-keys/guards/api-key.guard.ts
2import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import * as bcrypt from 'bcrypt';
6import { ApiKey } from '../entities/api-key.entity';
7
8@Injectable()
9export class ApiKeyGuard implements CanActivate {
10 constructor(
11 @InjectRepository(ApiKey)
12 private readonly apiKeyRepo: Repository<ApiKey>,
13 ) {}
14
15 async canActivate(context: ExecutionContext): Promise<boolean> {
16 const request = context.switchToHttp().getRequest();
17 const authHeader = request.headers['authorization'];
18
19 if (!authHeader) {
20 throw new UnauthorizedException('Missing Authorization header');
21 }
22
23 // Support both "Authorization: Bearer sk_live_..."
24 // and "Authorization: sk_live_..."
25 const rawKey = authHeader.startsWith('Bearer ')
26 ? authHeader.slice(7)
27 : authHeader;
28
29 // Narrow the candidate set by prefix so we only bcrypt-compare the keys
30 // that could possibly match (live vs test environment).
31 const prefix = rawKey.startsWith('sk_live_') ? 'sk_live_' : 'sk_test_';
32
33 const candidates = await this.apiKeyRepo.find({
34 where: { prefix, active: true },
35 });
36
37 for (const candidate of candidates) {
38 const isValid = await bcrypt.compare(rawKey, candidate.hash);
39 if (isValid) {
40 // Attach key info to request for downstream use
41 request.apiKey = candidate;
42 request.apiKeyClient = {
43 id: candidate.id,
44 name: candidate.name,
45 scope: candidate.scope,
46 tenantId: candidate.tenantId,
47 };
48
49 // Update last used timestamp (fire-and-forget)
50 this.apiKeyRepo.update(candidate.id, { lastUsedAt: new Date() }).catch(() => {});
51
52 return true;
53 }
54 }
55
56 throw new UnauthorizedException('Invalid API key');
57 }
58}The guard iterates over candidate keys that match the prefix. In most deployments there are fewer than a hundred active keys per prefix, so the iteration is fast even without caching. For higher throughput, add an in-memory cache that maps key hashes to API key records and invalidates entries when keys are rotated or revoked.
Apply the guard to any route or controller that should accept API key authentication:
1@Controller('api/integrations')
2@UseGuards(ApiKeyGuard)
3export class IntegrationsController {
4 @Get('data')
5 async getData(@Request() req) {
6 // req.apiKeyClient.scope contains the key's permissions
7 if (!req.apiKeyClient.scope.includes('read')) {
8 throw new ForbiddenException('API key does not have read access');
9 }
10 return this.integrationsService.getData(req.apiKeyClient.tenantId);
11 }
12}For more on the project structure where this guard lives, see our NestJS project structure guide.

Key Scopes — Read-Only vs Admin Keys
Not every integration needs full API access. A monitoring script that checks endpoint health should not be able to delete resources. Provide granular scopes that customers attach to each key.
1// Predefined scope catalogue
2export const API_KEY_SCOPES = {
3 READ: { key: 'read', description: 'Read-only access to resources' },
4 WRITE: { key: 'write', description: 'Create and update resources' },
5 ADMIN: { key: 'admin', description: 'Full access including deletion' },
6 BILLING_READ: { key: 'billing:read', description: 'View invoices and payment history' },
7 BILLING_WRITE: { key: 'billing:write', description: 'Manage billing settings' },
8 USERS_READ: { key: 'users:read', description: 'View user information' },
9 USERS_WRITE: { key: 'users:write', description: 'Create and update users' },
10} as const;When creating a key, the customer selects one or more scopes from this catalogue. The guard checks the key's scopes before allowing the operation:
1function requireScope(scope: string) {
2 return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
3 const originalMethod = descriptor.value;
4 descriptor.value = function (...args: any[]) {
5 const request = args[0]; // assumes @Req() is first parameter
6 if (!request.apiKeyClient.scope.includes(scope) &&
7 !request.apiKeyClient.scope.includes('admin')) {
8 throw new ForbiddenException(`Scope '${scope}' is required`);
9 }
10 return originalMethod.apply(this, args);
11 };
12 };
13}The admin scope implies all permissions. This follows the principle that scopes are additive — admin access should never be restricted by the check logic. Every scope check should pass if the key has the admin scope, even if the specific scope is not listed.
Key Rotation — How to Rotate Without Breaking Integrations
Key rotation is the most overlooked feature in API key systems. When a customer needs to rotate a compromised key, they should not have to coordinate a deployment window. Implement a dual-key grace period.
1// Method on ApiKeysService
2async rotateApiKey(
3 keyId: string,
4 gracePeriodHours: number = 48,
5): Promise<{ newKey: string }> {
6 const oldKey = await this.apiKeyRepo.findOneBy({ id: keyId });
7 if (!oldKey) throw new NotFoundException('API key not found');
8
9 // Generate new key
10 const { rawKey } = this.generateApiKey();
11 const hash = await hashApiKey(rawKey);
12
13 // Deactivate old key after grace period
14 const newKey = await this.apiKeyRepo.save({
15 name: oldKey.name,
16 prefix: oldKey.prefix,
17 hash,
18 lastFour: rawKey.slice(-4),
19 scope: oldKey.scope,
20 active: true,
21 createdBy: oldKey.createdBy,
22 tenantId: oldKey.tenantId,
23 rotatedFromId: oldKey.id,
24 });
25
26 // Mark old key as rotated — it stays active for the grace period
27 await this.apiKeyRepo.update(oldKey.id, {
28 rotatedAt: new Date(),
29 rotatedFromId: null,
30 });
31
32 // Schedule deactivation after grace period
33 setTimeout(async () => {
34 await this.apiKeyRepo.update(oldKey.id, { active: false });
35 }, gracePeriodHours * 60 * 60 * 1000);
36
37 return { newKey: rawKey };
38}One honest caveat about that setTimeout: it lives in process memory. Deploy or restart the server during the 48-hour window and the timer evaporates, leaving the old key active forever — the exact opposite of what rotation is for. In production, schedule the deactivation as a durable background job (or a periodic sweep that deactivates keys whose grace period has passed) so it survives a restart. The setTimeout is here to show the intent, not to be copied into a deploy.
The guard should accept both the old and new keys during the grace period:
1// In the guard, also check rotated keys
2import { Not, IsNull } from 'typeorm';
3
4const candidates = await this.apiKeyRepo.find({
5 where: [
6 { prefix, active: true },
7 { prefix, rotatedAt: Not(IsNull()), active: true },
8 ],
9});The grace period lets customers update their integration at their own pace. If the rotation was done proactively (not in response to a breach), 48 hours is enough for most teams to update their configuration. If the rotation is in response to a suspected compromise, the customer can manually deactivate the old key immediately.
Rate Limiting Per API Key
API keys need rate limits that are independent of user-based rate limits. A misconfigured CI/CD pipeline should not be able to saturate your API and degrade service for other customers.
1// src/api-keys/guards/api-key-rate-limit.guard.ts
2import { Injectable, CanActivate, ExecutionContext, HttpException } from '@nestjs/common';
3import { InjectRedis } from '@nestjs-modules/ioredis';
4import { Redis } from 'ioredis';
5
6@Injectable()
7export class ApiKeyRateLimitGuard implements CanActivate {
8 constructor(@InjectRedis() private readonly redis: Redis) {}
9
10 async canActivate(context: ExecutionContext): Promise<boolean> {
11 const request = context.switchToHttp().getRequest();
12 const apiKeyId = request.apiKey?.id;
13 if (!apiKeyId) return true; // not an API key request, skip
14
15 const key = `ratelimit:apikey:${apiKeyId}`;
16 const windowMs = 60 * 1000; // 1 minute window
17 const maxRequests = 100; // 100 requests per minute per key
18
19 const current = await this.redis.incr(key);
20 if (current === 1) {
21 await this.redis.expire(key, Math.ceil(windowMs / 1000));
22 }
23
24 if (current > maxRequests) {
25 throw new HttpException('Rate limit exceeded for this API key', 429);
26 }
27
28 return true;
29 }
30}Apply this guard after the API key guard on routes that serve API key authenticated traffic:
1@Controller('api/integrations')
2@UseGuards(ApiKeyGuard, ApiKeyRateLimitGuard)
3export class IntegrationsController {
4 // ...
5}For a deeper dive into rate limiting patterns in NestJS, see our API rate limiting implementation guide. The per-key approach described there uses the same Redis-backed sliding window pattern.

API Key Management UI
Your customers need a dashboard where they can create, list, view, rotate, and revoke API keys. The UI should follow the authentication patterns established by the JWT authentication with refresh tokens guide.
1// apps/web/src/app/settings/api-keys/page.tsx
2export default async function ApiKeysPage() {
3 const keys = await api.fetchApiKeys();
4
5 return (
6 <div className="space-y-6">
7 <h1>API Keys</h1>
8 <CreateApiKeyForm />
9 <div className="space-y-4">
10 {keys.map((key) => (
11 <ApiKeyCard
12 key={key.id}
13 name={key.name}
14 prefix={key.prefix}
15 lastFour={key.lastFour}
16 scope={key.scope}
17 createdAt={key.createdAt}
18 lastUsedAt={key.lastUsedAt}
19 onRotate={() => handleRotate(key.id)}
20 onRevoke={() => handleRevoke(key.id)}
21 />
22 ))}
23 </div>
24 </div>
25 );
26}Each card shows the key name, prefix with last four characters, scope badges, creation date, and last usage timestamp. The "Show" button reveals the full key only at creation time. After that, only "Rotate" and "Revoke" actions are available.
The API endpoints for the management UI:
1@Controller('api-keys')
2@UseGuards(JwtAuthGuard) // must be authenticated as a user
3export class ApiKeysController {
4 @Post()
5 async create(@Body() dto: CreateApiKeyDto, @User() user) {
6 const { id, rawKey, lastFour } = await this.apiKeysService.createApiKey(
7 dto.name,
8 dto.scope,
9 user.tenantId,
10 user.id,
11 );
12 return { id, rawKey, lastFour }; // rawKey shown once
13 }
14
15 @Get()
16 async list(@User() user) {
17 const keys = await this.apiKeysService.findByTenant(user.tenantId);
18 return keys.map((k) => ({
19 id: k.id,
20 name: k.name,
21 prefix: k.prefix,
22 lastFour: k.lastFour,
23 scope: k.scope,
24 createdAt: k.createdAt,
25 lastUsedAt: k.lastUsedAt,
26 active: k.active,
27 }));
28 }
29
30 @Post(':id/rotate')
31 async rotate(@Param('id') id: string, @User() user) {
32 return this.apiKeysService.rotateApiKey(id);
33 }
34
35 @Post(':id/revoke')
36 async revoke(@Param('id') id: string, @User() user) {
37 await this.apiKeysService.revokeApiKey(id);
38 return { status: 'revoked' };
39 }
40}The management UI is authenticated with the same JWT guard your application already uses. This means only logged-in users with the appropriate permissions can create or manage API keys. The API key guard is separate — it is used by the external API routes that receive API key authentication.
Putting It All Together
The complete flow works like this:
- A customer logs into your SaaS dashboard and navigates to the API Keys page.
- They create a new key with a name like "Production CI/CD" and scopes
["read", "billing:read"]. - Your application generates the key, hashes it, stores the hash and metadata, and shows the raw key exactly once.
- The customer copies the key and configures it in their CI/CD pipeline as an environment variable.
- Their pipeline calls your API with the header
Authorization: Bearer sk_live_a1b2c3d4.... - The
ApiKeyGuardextracts the key, finds candidate keys by prefix, performs a bcrypt comparison, and attaches the key metadata to the request. - The
ApiKeyRateLimitGuardchecks the per-key rate limit in Redis. - The route handler checks the key's scopes before performing the operation.
- When the customer needs to rotate the key, they do it through the dashboard. The old key works for 48 hours while they update their configuration.
Common Mistakes
Not hashing API keys. Storing keys in plain text means a database breach exposes every integration your customers have built. Hash with bcrypt at cost factor 10 minimum.
Showing the full key after creation. After the initial creation response, only show the last four characters. If a customer loses their key, they must rotate it.
No key rotation support. Every API key system needs a rotation flow. Without it, customers will be afraid to rotate keys because they cannot afford the downtime. The dual-key grace period removes that fear.
Rate limiting only by user, not by key. A single API key can make requests faster than a human user. Without per-key rate limits, one misconfigured integration can take down your API for every customer.
Global scopes without granularity. If every key has admin-level access, a compromised CI/CD key can delete production data. Default to the minimum scope and let customers request additional permissions.
Conclusion
An API key authentication system is infrastructure, not a feature. The schema, generation, hashing, guard, scoping, rotation, and rate limiting patterns in this post form a complete system that your customers can rely on for server-to-server integrations.
The investment is small — a database table, a guard, a service, and a management UI page — but the payoff is large. Every automated integration your customers build using API keys increases their investment in your platform. A well-designed API key system makes it easy for them to integrate and hard for them to leave.
If you are building a SaaS platform that exposes a public API, start with these patterns. Add OAuth2 client credentials later if you need token expiration and refresh. For most use cases, a well-scoped API key with rotation support covers 90% of what your customers need.
Frequently Asked Questions
API key authentication in NestJS uses a secret token passed in HTTP headers to authenticate server-to-server API requests. Unlike JWT tokens that are issued per-user, API keys are per-application or per-integration and often have scoped permissions. NestJS implements API key auth using a custom guard that extracts the key from the Authorization header, looks up its hash in the database, and attaches the associated client to the request.
API keys must never be stored in plain text. Hash them with bcrypt (cost factor 10-12) before inserting into the database. Store only the hash and the last four characters of the key (for UI display). When a request arrives, hash the provided key and compare against stored hashes. This limits exposure if the database is compromised.
Generate API keys using Node.js crypto.randomBytes with at least 32 bytes of entropy. Encode the output as a hex or base64url string. Prefix the key with a recognizable identifier (e.g., 'sk_live_'), and never log or return the full key after the initial creation response. A well-formed API key looks like sk_live_a1b2c3d4e5f6... with 40+ characters of entropy.
API key scopes restrict what operations a key can perform. Common scopes include read-only (can fetch data but not mutate), write (can create and update), admin (full access), and resource-specific scopes (billing:read, users:write). Scopes are stored as an array on the API key record and checked in the guard alongside key validity.
Implement a dual-key system with a grace period. When a client rotates a key, generate a new key immediately and keep the old key active for 24-48 hours. Mark the old key with a 'rotated_at' timestamp so you know when to expire it. During the grace period, accept both keys. After the grace period, reject the old key. This lets clients update their configuration without downtime.
