Trusted callers sign every request with a shared secret. The implementation is packages/security/src/index.ts (@perform/security). It fronts the internal provisioning routes, the admin default routes, the whole web gateway and, when a request claims to come from the web proxy, the public signing lane.

Headers

Canonical string

Five fields joined by newlines:
Three details cause most signature mismatches:
  • The path includes the query string. Sign exactly what you send, in the same order and encoding.
  • The body hash covers bytes. The server hashes the raw buffer captured by the body parser’s verify hook, not a re-serialized object. Sign the exact string or bytes you put on the wire. Binary bodies (a media upload part) are hashed as bytes, never decoded as UTF-8 first.
  • An empty body hashes the empty string. A GET signs sha256('').

Verification order

requireServiceAuth({ nonceStore, secrets, windowMs? }) checks in this order. Every failure is 401 UNAUTHORIZED. On success it sets req.service = { id: serviceId }. The nonce is consumed last, after the signature is verified. An attacker without the secret cannot burn nonces or fill the store.

The nonce store

createRedisNonceStore(redis, prefix = 'perform:nonce') implements it with one atomic command:
consume returns true when the key was set (first use) and false when it already existed (replay). The TTL is the window in seconds plus 60, so a nonce is remembered slightly longer than its timestamp can be valid. The store uses the shared Redis connection from the queue factory. If Redis is down, consume rejects and the request fails with 500. Signed lanes fail closed.

Replay protection without a signature

replayProtection({ nonceStore, windowMs? }) is the lighter guard in front of the bearer lane. It checks only freshness and uniqueness: It does not bind the nonce to the request, so by itself it only stops a verbatim replay of a captured request. On the bearer lane the session token is the credential. Signed lanes are mounted before router.use(replay), because requireServiceAuth already consumed the nonce. Running both would reject every signed request as a replay.

Which lanes need what

Older notes say every authenticated /v1 request carries a nonce. That is no longer true. The trainee app, API key callers and webhooks send none.

Signing a request

@perform/security ships a client for server to server callers:
request serializes the body once with JSON.stringify, signs that exact string, sets the four headers plus content-type: application/json, and throws on a non 2xx response. Extra headers (the web user context) go in init.headers. The web app has its own equivalent, coreApiFetch in its core-api.ts, and @repo/auth signs by hand in lib/provision-coach.ts for POST /v1/internal/coaches. All three build the same canonical string. To sign from a shell for debugging:

Exports

Operating notes

  • SERVICE_AUTH_SECRET must be the same value in the API and in the web app. Rotate both together. There is one secret per service id and no overlap window, so a rotation needs both sides deployed at once.
  • Clock skew larger than 5 minutes between caller and API makes every request fail with stale or invalid timestamp.
  • A reverse proxy that rewrites the path or re-encodes the query string breaks signatures, because the server signs req.originalUrl.
  • The logger does not redact x-signature, but the HTTP log serializer only records host, content-type and x-request-id from the request headers, so signatures and nonces do not reach the logs.