API Keys
API keys provide authentication for machine-to-machine communication, background jobs, and external integrations.
Overview
| Use Case | Recommendation |
|---|---|
| User authentication | Use JWT tokens |
| Server-to-server | Use API keys |
| Background jobs | Use API keys |
| External integrations | Use API keys |
| Mobile/Web apps | Use JWT tokens |
API Key Structure
apso_live_org123_abc123def456...
└─┬─┘ └┬┘ └─┬─┘ └─────┬──────┘
│ │ │ │
prefix env org-id keyImplementation
API Key Entity
// src/entities/api-key.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Organization } from './organization.entity';
@Entity('api_keys')
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
keyHash: string;
@Column()
prefix: string;
@ManyToOne(() => Organization)
organization: Organization;
@Column()
organizationId: string;
@Column({ type: 'simple-array', nullable: true })
scopes: string[];
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date | null;
@Column({ type: 'timestamp', nullable: true })
lastUsedAt: Date | null;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}API Key Service
// src/modules/api-keys/api-keys.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiKey } from '../../entities/api-key.entity';
import * as crypto from 'crypto';
@Injectable()
export class ApiKeysService {
constructor(
@InjectRepository(ApiKey)
private readonly repository: Repository<ApiKey>,
) {}
async create(
organizationId: string,
name: string,
scopes?: string[],
expiresAt?: Date,
): Promise<{ apiKey: ApiKey; rawKey: string }> {
// Generate key
const rawKey = this.generateKey(organizationId);
const keyHash = this.hashKey(rawKey);
const prefix = rawKey.substring(0, 12);
const apiKey = this.repository.create({
name,
keyHash,
prefix,
organizationId,
scopes,
expiresAt,
});
await this.repository.save(apiKey);
// Return raw key only once - it won't be retrievable later
return { apiKey, rawKey };
}
async validate(rawKey: string): Promise<ApiKey | null> {
const keyHash = this.hashKey(rawKey);
const apiKey = await this.repository.findOne({
where: { keyHash },
relations: ['organization'],
});
if (!apiKey) return null;
// Check expiration
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
return null;
}
// Update last used
apiKey.lastUsedAt = new Date();
await this.repository.save(apiKey);
return apiKey;
}
async revoke(id: string, organizationId: string): Promise<void> {
await this.repository.delete({ id, organizationId });
}
private generateKey(organizationId: string): string {
const env = process.env.NODE_ENV === 'production' ? 'live' : 'test';
const orgPrefix = organizationId.substring(0, 6);
const randomPart = crypto.randomBytes(24).toString('hex');
return `apso_${env}_${orgPrefix}_${randomPart}`;
}
private hashKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex');
}
}API Key Guard
// src/common/guards/api-key.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly apiKeysService: ApiKeysService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const apiKey = this.extractApiKey(request);
if (!apiKey) {
throw new UnauthorizedException('API key required');
}
const validKey = await this.apiKeysService.validate(apiKey);
if (!validKey) {
throw new UnauthorizedException('Invalid or expired API key');
}
// Set user context for downstream services
request.user = {
id: `apikey:${validKey.id}`,
organizationId: validKey.organizationId,
isApiKey: true,
scopes: validKey.scopes,
};
return true;
}
private extractApiKey(request: any): string | undefined {
// Check header
const headerKey = request.headers['x-api-key'];
if (headerKey) return headerKey;
// Check Authorization header
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type === 'Bearer' && token?.startsWith('apso_')) {
return token;
}
return undefined;
}
}Combined Auth Guard
Support both JWT and API key authentication:
// src/common/guards/combined-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiKeyGuard } from './api-key.guard';
@Injectable()
export class CombinedAuthGuard implements CanActivate {
constructor(
private readonly jwtGuard: JwtAuthGuard,
private readonly apiKeyGuard: ApiKeyGuard,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
const apiKeyHeader = request.headers['x-api-key'];
// API key takes precedence
if (apiKeyHeader || authHeader?.includes('apso_')) {
return this.apiKeyGuard.canActivate(context);
}
// Fall back to JWT
if (authHeader) {
return this.jwtGuard.canActivate(context);
}
throw new UnauthorizedException();
}
}API Key Controller
// src/modules/api-keys/api-keys.controller.ts
import { Controller, Post, Get, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
import { AuthGuard } from '../../common/guards/auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('api-keys')
@UseGuards(AuthGuard)
export class ApiKeysController {
constructor(private readonly service: ApiKeysService) {}
@Post()
async create(
@CurrentUser() user: { organizationId: string },
@Body() body: { name: string; scopes?: string[]; expiresAt?: string },
) {
const { apiKey, rawKey } = await this.service.create(
user.organizationId,
body.name,
body.scopes,
body.expiresAt ? new Date(body.expiresAt) : undefined,
);
return {
id: apiKey.id,
name: apiKey.name,
prefix: apiKey.prefix,
key: rawKey, // Only returned once!
scopes: apiKey.scopes,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
};
}
@Get()
async list(@CurrentUser() user: { organizationId: string }) {
return this.service.findAll(user.organizationId);
}
@Delete(':id')
async revoke(
@Param('id') id: string,
@CurrentUser() user: { organizationId: string },
) {
await this.service.revoke(id, user.organizationId);
return { success: true };
}
}Usage Examples
cURL
# Using X-API-Key header
curl -H "X-API-Key: apso_live_abc123_xyz789..." \
https://api.yourapp.com/api/v1/projects
# Using Authorization header
curl -H "Authorization: Bearer apso_live_abc123_xyz789..." \
https://api.yourapp.com/api/v1/projectsNode.js
const response = await fetch('https://api.yourapp.com/api/v1/projects', {
headers: {
'X-API-Key': process.env.APSO_API_KEY,
},
});Python
import requests
response = requests.get(
'https://api.yourapp.com/api/v1/projects',
headers={'X-API-Key': os.environ['APSO_API_KEY']}
)Scopes
Restrict API key access with scopes:
// Available scopes
const SCOPES = {
'projects:read': 'Read projects',
'projects:write': 'Create and update projects',
'tasks:read': 'Read tasks',
'tasks:write': 'Create and update tasks',
'*': 'Full access',
};
// Scope guard
@Injectable()
export class ScopeGuard implements CanActivate {
constructor(private readonly requiredScopes: string[]) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user.isApiKey) return true; // JWT users have full access
const hasScope = this.requiredScopes.some(
scope => user.scopes?.includes(scope) || user.scopes?.includes('*')
);
if (!hasScope) {
throw new ForbiddenException('Insufficient scope');
}
return true;
}
}Security Best Practices
- Never log API keys - Only log prefixes for debugging
- Hash keys in storage - Store SHA-256 hashes, not raw keys
- Set expiration - Require key rotation with expiry dates
- Use scopes - Limit keys to required permissions
- Monitor usage - Track lastUsedAt and watch for anomalies
- Revoke immediately - Remove compromised keys without delay
Related
Last updated on