Api Key Exposure in Nestjs with Hmac Signatures
Api Key Exposure in Nestjs with Hmac Signatures — how this specific combination creates or exposes the vulnerability
When an API built with NestJS uses HMAC signatures to authenticate requests, exposure of the signing key or weak handling of the signature can lead to full API compromise. HMAC is a symmetric mechanism: the client and server share a secret key and the client signs a canonical representation of the request (often method, path, selected headers, and body). The server recomputes the signature and compares it, typically using a constant-time compare, to decide whether to authorize the request.
Exposure of the shared secret can occur in several ways within a NestJS application. Developers might accidentally log raw authorization headers that contain the signature alongside timestamps or nonces, and if logs are aggregated or retained, the secret becomes discoverable. More critically, if the secret is hard-coded in source files, stored in version control, or placed in environment files that are checked into repositories, any access to code or configuration repositories leads directly to key disclosure. Another common issue is weak signature construction: including variable or non-canonical fields (such as non-standard header order or optional query parameters) can cause the same logical request to produce multiple valid signatures, increasing the risk that an attacker can guess or replay a valid signature without knowing the secret.
In NestJS, an endpoint that expects an HMAC signature often reads headers such as x-api-key, x-request-timestamp, and x-signature, then reconstructs the signing string. If the implementation does not strictly validate the presence and format of these headers, an attacker may supply malformed or missing values that bypass validation or trigger errors that leak information about the expected format. Additionally, if timestamps or nonces are not enforced with a tight window and unique handling, replay attacks become feasible, where a captured signed request is resent within the validity window.
Runtime exposure also arises when responses or error messages reveal whether a provided key or signature format was recognized. Verbose errors that differentiate between bad signature, missing key, or invalid timestamp give attackers signals to iteratively refine requests. In a microservice context, if multiple services share the same key and one service logs or mishandles the key, the attack surface expands beyond the NestJS application to downstream systems. Therefore, secure handling of HMAC in NestJS requires strict control of the shared secret, canonical and deterministic signing logic, tight validation of all signed components, and careful handling of errors and logs to avoid inadvertent disclosure.
Hmac Signatures-Specific Remediation in Nestjs — concrete code fixes
To reduce the risk of API key exposure when using HMAC signatures in NestJS, adopt deterministic signing, strict validation, and safe handling of secrets and errors. Below are concrete patterns and code examples that address common pitfalls.
- Use environment variables and a secrets manager for the HMAC secret
// config/hmac.config.ts
import { ConfigService } from '@nestjs/config';
export function getHmacSecret(configService: ConfigService): string {
const secret = configService.get('HMAC_SECRET');
if (!secret || secret.trim().length === 0) {
throw new Error('HMAC_SECRET is required and must be non-empty');
}
return secret;
}
- Implement canonical signing logic that excludes variable or sensitive headers
// auth/hmac.util.ts
import { createHmac } from 'crypto';
export function buildSigningString(req: {
method: string;
path: string;
timestamp: string;
nonce: string;
body?: string;
headersToInclude?: string[];
}): string {
const { method, path, timestamp, nonce, body = '', headersToInclude = [] } = req;
const parts = [`${method.toUpperCase()}
${path}
${timestamp}
${nonce}
${body}`];
// Include selected headers in a deterministic order if needed
// Example: parts.push(headersToInclude.map(h => `${h}:${req[h]}`).join('&'));
return parts.join('\n');
}
export function computeHmac(secret: string, message: string): string {
return createHmac('sha256', secret).update(message).digest('hex');
}
- Validate signature in an interceptor with constant-time comparison and strict header checks
// auth/hmac.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable } from 'rxjs';
import { timingSafeEqual } from 'crypto';
import { buildSigningString, computeHmac } from './hmac.util';
@Injectable()
export class HmacInterceptor {
constructor(private readonly configService: ConfigService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const providedTimestamp = request.headers['x-request-timestamp'];
const providedNonce = request.headers['x-nonce'];
const providedSignature = request.headers['x-signature'];
const apiKey = request.headers['x-api-key'];
if (!providedTimestamp || !providedNonce || !providedSignature || !apiKey) {
throw new Error('Missing required HMAC headers');
}
// Enforce timestamp window to prevent replay (e.g., 5 minutes)
const now = Date.now();
const reqTime = parseInt(providedTimestamp, 10);
if (Math.abs(now - reqTime) > 5 * 60 * 1000) {
throw new Error('Request timestamp out of acceptable window');
}
// Build canonical string deterministically; ensure header names are normalized
const signingString = buildSigningString({
method: request.method,
path: request.originalUrl.split('?')[0],
timestamp: providedTimestamp,
nonce: providedNonce,
body: request.rawBody || '',
});
const expectedSignature = computeHmac(this.configService.get('HMAC_SECRET'), signingString);
// Constant-time comparison to avoid timing attacks
const expectedBuf = Buffer.from(expectedSignature, 'utf8');
const providedBuf = Buffer.from(providedSignature, 'utf8');
if (!timingSafeEqual(expectedBuf, providedBuf)) {
throw new Error('Invalid signature');
}
// Optionally validate apiKey format or existence against a secure store
request.apiKey = apiKey;
return next.handle();
}
}
- Handle errors and logs safely
// main.ts or a dedicated filter
import { HttpException, HttpStatus, Injectable, NestInterceptor, ExecutionContext, Catch } from '@nestjs/common';
@Catch()
@Injectable()
export class HttpExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const req = ctx.getRequest();
// Generic error to avoid leaking validation details
response.status(HttpStatus.UNAUTHORIZED).json({
message: 'Unauthorized',
requestId: req.id,
});
// Log securely without exposing secrets or full signatures
console.error({
requestId: req.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString(),
error: exception instanceof Error ? exception.message : 'Unknown error',
});
}
}
- Use middleware to ensure body is available for signing
// body-parser.middleware.ts or a custom middleware
import { Injectable, NestMiddleware } from '@nestjs/common';
@Injectable()
export class RawBodyMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
let data = '';
req.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
req.on('end', () => {
req.rawBody = data;
next();
});
}
}
By combining these practices—secure secret storage, canonical and deterministic signing, strict validation, constant-time comparisons, and safe error/logging—you reduce the likelihood that an API key is exposed or that a valid HMAC signature can be forged or replayed in a NestJS application.