Skip to main content
Every internal call between Guild services carries a signature. Not a JWT. Not a session. A fresh HMAC-SHA256 over the request, signed with a shared secret, with a timestamp and a nonce that makes replays detectable. This page is the contract.

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:
Newlines are \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 with 401 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 Redis SET 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:
Pseudocode for the server middleware:
The middleware lives in @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.
  1. Operational simplicity. No per-service client certificates to provision, rotate, or revoke. One shared secret, easy to rotate.
  2. Debuggability. A signed request can be inspected. Headers and body are human-readable. mTLS hides the auth in the transport layer.
  3. 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.
If we ever split services across hosts (multi-VPS deployment behind a load balancer), the HMAC contract continues working — we’d just need to route traffic through a private network. We wouldn’t need to swap protocols.

The BFF layer

The web frontend (Next.js at guild.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 shared SERVICE_AUTH_SECRET is rotated quarterly. The rotation procedure:
  1. Generate a new secret. Add it to the VPS environment alongside the old one as SERVICE_AUTH_SECRET_NEXT.
  2. Deploy. The middleware accepts a request signed with either secret during the transition.
  3. Once all services have picked up the new secret, switch the primary to _NEXT.
  4. After a confirmation window (24 hours of clean traffic), remove the old secret.
Zero downtime. Atomic enough that an in-flight request never fails due to the rotation.

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.