HMAC Authentication

How the IAM service verifies inter-service requests using HMAC-SHA256 signatures, replay protection via a nonce cache, clock-skew tolerance, timing-safe comparison, and how to sign requests from calling services.

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.

HMAC authentication is optional. Configure it in 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

HeaderTypeDescription
X-Client-IdstringThe identifier of the calling service. Must match the clientId value in the IAM configuration.
X-TimestampstringThe client's current time in milliseconds since the Unix epoch. Used for clock-skew validation.
X-Request-IDstringA unique identifier for this specific request. Used for replay detection. Must never be reused.
X-SignaturestringThe 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()
The signature covers the 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
Only local health checks are exempt. A 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 }),
})
The 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.

ReasonCause
Missing auth headersOne or more required headers (X-Client-Id, X-Timestamp, X-Signature, X-Request-ID) are absent
Unknown clientThe X-Client-Id does not match the configured clientId
Stale timestampThe X-Timestamp is outside the maxClockSkew window
Replay detectedThe X-Request-ID was already used within the nonce cache TTL
Buffer Doesn't matchThe 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.

Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, or environment variables injected by your orchestrator) to distribute the shared secret. Never hard-code it or commit it to version control.
There is no grace period or dual-key support during rotation. All calling services must be updated before or immediately after the IAM service restarts. Plan the rotation during a maintenance window or use a rolling deployment strategy where the IAM service is the last to restart.

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.

service.Hmac.sharedSecret
string required
The shared secret key used to compute and verify HMAC-SHA256 signatures. Must be identical on the IAM service and all calling services. Use a cryptographically random string of at least 32 characters.
service.Hmac.clientId
string required
The expected client identifier. Every request's X-Client-Id header must exactly match this value. Use a descriptive name for the calling service (e.g. 'billing-service', 'notification-worker').
service.Hmac.maxClockSkew
number
Maximum allowed difference in milliseconds between the server's current time and the request's 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.
If your services run in containers with NTP synchronization, a 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

SystemIntegration point
Middleware chainHMAC runs before body parsing and cookie parsing, so invalid requests are rejected with minimal resource consumption
Rate limitingHMAC rejections do not consume rate limit tokens because the middleware rejects before reaching the rate limiter
LoggingEvery HMAC validation (success or failure) is logged with the client ID, enabling audit trails for inter-service calls
Health checksGET /health from localhost bypasses HMAC, allowing container health probes without credentials
Logo