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:- 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
verifyhook, 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
GETsignssha256('').
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_SECRETmust 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 recordshost,content-typeandx-request-idfrom the request headers, so signatures and nonces do not reach the logs.