The shape of a signed request
Four headers do all the work.The four headers
The signature, the timestamp, and the nonce are computed fresh per request. The actor is fixed per service.
The canonical signing string
The signing string is constructed deterministically so both sides compute identical bytes:\n. The method is uppercase. The path is the exact path as called (no query rewrites). The body is the raw bytes of the request (matters for any non-JSON content; matters for whitespace in JSON too). Timestamp is the value of x-guild-timestamp. Nonce is the value of x-guild-nonce. Actor is the value of x-guild-actor.
The HMAC is computed over those bytes using the shared SERVICE_AUTH_SECRET environment variable, which lives only on the VPS and is never logged.
Why HMAC-SHA256 and not JWT
JWT is overkill for this use case and has known footguns.
HMAC-SHA256 over
(method, path, body, timestamp, nonce) is what AWS uses for SigV4, what Stripe uses for webhook signatures, what almost every payment-grade API uses for service-to-service. It’s the boring correct answer.
The 300-second skew window
The middleware accepts requests whose timestamp is within ±300 seconds of the server’s current clock. Wider than that, the request is rejected with401 timestamp_out_of_window.
Why 300 seconds: long enough to tolerate routine clock drift between hosts (NTP keeps everything in sync, but occasional ±30s drift is normal). Short enough that an old, captured signature becomes useless quickly.
A clock-out-of-sync host that’s been drifting for hours will start to fail signature checks. We monitor for this; an alert fires before signatures actually fail.
The nonce and replay protection
The nonce is a 16-character random string from the Crockford Base32 alphabet. (Crockford Base32 because it’s URL-safe, case-insensitive, and unambiguous — no 0/O or 1/I confusion.) Generated by the caller for each request. On the server side, we cache the nonce in Redis with a TTL of 600 seconds (twice the skew window, with a safety margin). The cache key is the nonce itself; the value is a marker. Before accepting a request, the middleware does a RedisSET nonce_value 1 NX EX 600.
A captured request from 60 seconds ago can’t be replayed: its nonce is in our cache, so the replay is rejected.
A captured request from 700 seconds ago can’t be replayed either: its timestamp is outside the 300s window, so the skew check rejects it before we even look at the nonce.
The two checks (timestamp + nonce) cover the full attack surface.
Constant-time signature comparison
crypto.timingSafeEqual on the recomputed signature versus the provided one. No early-return string compare anywhere.
Constant-time comparison defends against timing oracles where an attacker can iteratively narrow down a correct signature by measuring how long the server takes to reject increasingly correct guesses. In practice, this attack is hard to mount against a network-distant target — but the defence is one line of code and there’s no reason not to have it.
What happens on failure
The
nonce_replay and signature_invalid failures write structured logs with the source service and the raw timestamp. Repeated failures from the same actor in a short window trigger an alert.
A worked example
Pseudocode for the caller side:@guild/auth so every service mounts the same code. Drift between services is impossible.
Why this protocol, not mTLS
mTLS is the textbook answer for service-to-service authentication. We considered it. We picked HMAC for three reasons.- Operational simplicity. No per-service client certificates to provision, rotate, or revoke. One shared secret, easy to rotate.
- Debuggability. A signed request can be inspected. Headers and body are human-readable. mTLS hides the auth in the transport layer.
- Loopback-only. All inter-service traffic is on
127.0.0.1. The threat model is fundamentally different from cross-WAN service mesh. mTLS adds complexity we don’t need at this network topology.
The BFF layer
The web frontend (Next.js atguild.com.ng) doesn’t talk to core-api directly from the browser. Instead, it has its own BFF (backend-for-frontend) endpoints at /api/* that the browser hits. The BFF holds the SERVICE_AUTH_SECRET server-side and signs outbound calls to core-api on the user’s behalf.
The browser carries an httpOnly cookie with a session ULID. The BFF resolves the session, makes the signed call, returns the response. The browser never sees the service auth secret. It never sees core-api directly.
This means:
- A leaked secret is restricted to “secret on the BFF host” — a much smaller blast radius than “secret in the browser.”
- A compromised browser session can do only what the session-bound user can do, scoped by the BFF.
- The session model and the inter-service auth model are decoupled; either can be rotated independently.
Secret rotation
The sharedSERVICE_AUTH_SECRET is rotated quarterly. The rotation procedure:
- Generate a new secret. Add it to the VPS environment alongside the old one as
SERVICE_AUTH_SECRET_NEXT. - Deploy. The middleware accepts a request signed with either secret during the transition.
- Once all services have picked up the new secret, switch the primary to
_NEXT. - After a confirmation window (24 hours of clean traffic), remove the old secret.
What to read next
Webhook security
External-facing signature verification (OPay, WhatsApp Cloud, etc.).
Data flow
How signals flow between services after the auth check passes.
Resilience & scale
How signed requests interact with the idempotency layer.