HMAC Authentication
The IAM service supports an optional HMAC authentication layer for service-to-service communication. When enabled, every inbound request must carry four headers: a client identifier, a millisecond timestamp, a unique request ID, and an HMAC-SHA256 signature computed from a shared secret. The service verifies the signature using a constant-time comparison, rejects replayed requests via an LRU nonce cache, and enforces a configurable clock-skew window.
This layer protects IAM endpoints from unauthorized access by services on the same private network. Without HMAC, any service that can reach the IAM port can call its API. With HMAC, only services that possess the shared secret can produce valid signatures.
service.Hmac only when you have internal services that call IAM endpoints directly without going through the user-facing client. User-facing requests (browser to IAM) use cookie-based JWT authentication instead.How it works
The hmacAuth middleware runs early in the Express middleware chain, before JSON body parsing and cookie parsing. It intercepts every inbound request and validates the four required headers before allowing the request to proceed.
Required headers
| Header | Type | Description |
|---|---|---|
X-Client-Id | string | The identifier of the calling service. Must match the clientId value in the IAM configuration. |
X-Timestamp | string | The client's current time in milliseconds since the Unix epoch. Used for clock-skew validation. |
X-Request-ID | string | A unique identifier for this specific request. Used for replay detection. Must never be reused. |
X-Signature | string | The hex-encoded HMAC-SHA256 signature of the request, computed from the shared secret. |
Signature computation
The signature is computed over a colon-separated string containing the client ID, timestamp, HTTP method, full request URL (including query string), and the request ID:
base = "{X-Client-Id}:{X-Timestamp}:{METHOD}:{originalUrl}:{X-Request-ID}"
signature = HMAC-SHA256(sharedSecret, base).hex()
For example, a POST request to /auth/user/refresh-session from the billing-service client:
base = "billing-service:1712419200000:POST:/auth/user/refresh-session:550e8400-e29b-41d4-a716-446655440000"
signature = HMAC-SHA256("my-shared-secret", base).hex()
originalUrl, not just the path. This means query parameters are included in the signed payload. A request to /api/users?page=2 produces a different signature than /api/users?page=3. This prevents an attacker from modifying query parameters on a captured request.Verification flow
The middleware performs five checks in sequence. The first failure short-circuits the chain and returns HTTP 401 with a reason string.
Header presence
All four headers (X-Client-Id, X-Timestamp, X-Signature, X-Request-ID) must be present. If any is missing, the request is rejected with 'Missing auth headers'.
Client identity
The X-Client-Id value must exactly match service.Hmac.clientId from the configuration. If it does not match, the request is rejected with 'Unknown client'.
Clock-skew validation
The absolute difference between the server's current time (Date.now()) and the X-Timestamp value must be less than or equal to maxClockSkew milliseconds. If the timestamp is outside this window, the request is rejected with 'Stale timestamp'.
This check serves two purposes: it prevents old captured requests from being replayed after the skew window expires, and it rejects requests with timestamps far in the future (which could indicate clock manipulation).
Replay detection
The X-Request-ID is checked against an LRU nonce cache. If the ID already exists in the cache, the request is rejected with 'Replay detected'. This catches an attacker replaying a valid request within the clock-skew window.
The nonce cache holds up to 5,000 entries with a TTL equal to maxClockSkew. Once the TTL expires, the nonce is evicted and a request with the same ID would pass the replay check, but it would then fail the clock-skew check because the original timestamp is now outside the window.
Timing-safe signature comparison
The middleware recomputes the signature from the shared secret and the same base string format. It then compares the expected signature against the received X-Signature using crypto.timingSafeEqual on the hex-decoded buffers.
Before comparing, it verifies that both buffers are the same length. If the lengths differ, the request is rejected immediately (a length mismatch is always invalid). The constant-time comparison prevents timing attacks that could leak information about the correct signature byte-by-byte.
On success, the request ID is added to the nonce cache with a TTL of maxClockSkew, and the middleware calls next() to proceed to the route handler.
Health check bypass
GET /health requests from localhost (127.0.0.1, ::1, or ::ffff:127.0.0.1) bypass HMAC validation entirely. This allows container orchestrators (Docker, Kubernetes) to run health checks without needing access to the shared secret.
// These requests skip HMAC validation:
// GET http://127.0.0.1:10000/health
// GET http://[::1]:10000/health
GET /health request from any non-local IP is subject to the full HMAC validation when the middleware is enabled.Signing requests from a calling service
The calling service must compute the signature using the same base string format and the same shared secret. Here is a complete implementation:
import crypto from 'node:crypto'
function signRequest(
method: string,
url: string,
secret: string,
clientId: string
): Record<string, string> {
const timestamp = Date.now().toString()
const requestId = crypto.randomUUID()
const base = `${clientId}:${timestamp}:${method}:${url}:${requestId}`
const signature = crypto.createHmac('sha256', secret).update(base).digest('hex')
return {
'X-Client-Id': clientId,
'X-Timestamp': timestamp,
'X-Signature': signature,
'X-Request-ID': requestId,
}
}
// Usage
const headers = signRequest(
'POST',
'/auth/user/refresh-session',
process.env.HMAC_SECRET!,
'billing-service'
)
const response = await fetch('https://iam.internal/auth/user/refresh-session', {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: refreshToken }),
})
X-Request-ID must be globally unique per request. Use crypto.randomUUID() or a similar UUID generator. Reusing a request ID within the clock-skew window triggers the replay detection and the request is rejected.Rejection handling
When HMAC validation fails, the reject function sends an HTTP 401 response with the reason string as the body. It also logs the rejection with the client ID for audit purposes.
| Reason | Cause |
|---|---|
Missing auth headers | One or more required headers (X-Client-Id, X-Timestamp, X-Signature, X-Request-ID) are absent |
Unknown client | The X-Client-Id does not match the configured clientId |
Stale timestamp | The X-Timestamp is outside the maxClockSkew window |
Replay detected | The X-Request-ID was already used within the nonce cache TTL |
Buffer Doesn't match | The recomputed HMAC signature does not match X-Signature |
Every rejection is logged at warn level with { Authorized: false, reason, clientId }. Successful validations are logged at info level with { Authorized: true, ClientID, Reason: 'Match' }.
Rotating the shared secret
To rotate the shared secret without downtime, update the services in the correct order:
Update the IAM service
Set the new sharedSecret in the IAM configuration and restart the service. The old secret is immediately invalid.
Update calling services
Deploy the updated sharedSecret to all calling services. Each service starts signing with the new secret immediately.
Monitor for failures
Watch the IAM logs for HMAC rejection entries (reason: 'Buffer Doesn't match'). These indicate a calling service is still using the old secret. Once no failures appear, the rotation is complete.
Middleware mounting
The HMAC middleware is conditionally mounted in the Express application based on the service.Hmac configuration. When the Hmac block is not present (or service is undefined), the middleware is skipped entirely and all requests proceed without signature validation.
// From the service bootstrap (simplified)
if (config.service?.Hmac) {
app.use(hmacAuth)
}
The middleware is mounted before express.json() and cookieParser(), meaning it runs before the request body is parsed. This is intentional: if the signature is invalid, the service rejects the request without spending resources on body parsing.
Configuration reference
All HMAC settings live under service.Hmac in the configuration.
X-Client-Id header must exactly match this value. Use a descriptive name for the calling service (e.g. 'billing-service', 'notification-worker').X-Timestamp. Requests outside this window are rejected with 'Stale timestamp'. The default is 300,000 ms (5 minutes). Lower values strengthen replay protection but require tighter clock synchronization between services.maxClockSkew of 30,000 ms (30 seconds) is a reasonable starting point. Increase it only if you observe legitimate rejections due to clock drift.Summary
| System | Integration point |
|---|---|
| Middleware chain | HMAC runs before body parsing and cookie parsing, so invalid requests are rejected with minimal resource consumption |
| Rate limiting | HMAC rejections do not consume rate limit tokens because the middleware rejects before reaching the rate limiter |
| Logging | Every HMAC validation (success or failure) is logged with the client ID, enabling audit trails for inter-service calls |
| Health checks | GET /health from localhost bypasses HMAC, allowing container health probes without credentials |