Skip to main content

HMAC Request Signatures

HmacSignatureMiddleware (v0.6.0) validates a signed request before the handler runs. Use it for server-to-server webhooks where the caller and the API share a secret per key id.

The signature is verified, the timestamp is enforced against a replay window, and the active key id is recorded into request attributes — all before the handler decides anything.

Minimal example

use BetterRoute\Middleware\Auth\ArrayHmacSecretProvider;
use BetterRoute\Middleware\Auth\HmacSignatureMiddleware;

$secrets = new ArrayHmacSecretProvider([
'kid-2026-prod' => $_ENV['HMAC_SECRET_PROD'],
'kid-2026-canary' => $_ENV['HMAC_SECRET_CANARY'],
]);

$signature = new HmacSignatureMiddleware(
secrets: $secrets,
signatureHeader: 'X-Signature',
timestampHeader: 'X-Timestamp',
keyIdHeader: 'X-Key-Id',
replayWindowSeconds: 300,
algorithm: 'sha256'
);

$router->post('/webhooks/intake', $handler)
->middleware([$signature])
->publicRoute();

->publicRoute() declares the route as intentionally public (no permission() callback) so WordPress lets it through. The signature middleware is the actual gate.

Constructor

new HmacSignatureMiddleware(
HmacSecretProviderInterface $secrets,
string $signatureHeader = 'X-Signature',
string $timestampHeader = 'X-Timestamp',
string $keyIdHeader = 'X-Key-Id',
int $replayWindowSeconds = 300,
string $algorithm = 'sha256',
?callable $now = null
);
  • algorithm must be a member of hash_hmac_algos(). Defaults to sha256.
  • replayWindowSeconds must be positive. Both past and future drift outside the window fail closed.
  • Inject a $now callable in tests.

Canonical input

Every request is reduced to a single string before HMAC:

timestamp + "\n" + method + "\n" + path + "\n" + sha256(body)
  • timestamp — the integer Unix epoch from the timestamp header.
  • method — uppercased HTTP method.
  • path — URL path of the request (no scheme/host, no query string).
  • sha256(body) — hex digest of the raw request body. Use an empty body for GET/DELETE.

The client signs the same canonical string with the shared secret and sends the result in X-Signature.

Accepted signature encodings

The middleware accepts any of the following representations and compares them in constant time via Crypto::equals():

  • lowercase hex
  • uppercase hex
  • base64
  • base64url (no padding)
  • the same four values prefixed with sha256= (e.g. sha256=ab12...)

This matches the formats produced by Stripe-style and GitHub-style webhook signers without forcing one shape.

Behavior

FailureStatusCode
Missing/blank X-Signature / X-Timestamp / X-Key-Id401signature_required
Timestamp not numeric401invalid_signature_timestamp
Timestamp outside ±replayWindowSeconds401stale_signature
Unknown key id (provider returns null/empty)401invalid_signature
Computed HMAC does not match any accepted encoding401invalid_signature

On success, the middleware writes safe metadata into the request context:

$context->attributes['hmac'] === [
'keyId' => 'kid-2026-prod',
'algorithm' => 'sha256',
];

The raw secret is never reflected.

Secret providers

HmacSecretProviderInterface is a one-method contract:

interface HmacSecretProviderInterface
{
public function secretFor(string $keyId): ?string;
}

ArrayHmacSecretProvider is a static map for the simple case. For production rotation, build a custom provider that reads from your secret store (Vault, AWS Secrets Manager, encrypted options, ...) and returns the matching secret per key id.

Key rotation

Use distinct kid values per active secret:

$secrets = new ArrayHmacSecretProvider([
'kid-2026-q1' => $_ENV['HMAC_SECRET_Q1'],
'kid-2026-q2' => $_ENV['HMAC_SECRET_Q2'],
]);

Rotate by:

  1. issue the new secret on a new kid;
  2. update the consumer to start signing with the new kid;
  3. wait for traffic on the old kid to drain;
  4. remove the old kid from the provider.

The middleware fails closed for unknown key ids, so removing a kid immediately invalidates anything still using it.

Combining with other middleware

  • Place HmacSignatureMiddleware before rate limiting so unauthenticated traffic does not consume the limit budget.
  • Combine with IpAllowlistMiddleware to restrict to known proxy networks (e.g. the partner's outbound CIDRs).
  • HMAC routes are usually ->publicRoute() from WordPress's perspective. The middleware is the actual auth.

Validation checklist

  • request signed with the expected secret returns 200;
  • request with a stale timestamp returns 401 stale_signature;
  • request with a tampered body returns 401 invalid_signature;
  • request with an unknown X-Key-Id returns 401 invalid_signature (does not leak which key ids exist);
  • successful requests carry hmac.keyId in attributes.

Common mistakes

  • Hashing the request after it has been mutated by a body parser. Always compute sha256(body) over the raw wire bytes.
  • Including the query string in path. The middleware computes the signature over the URL path only.
  • Letting clocks drift more than replayWindowSeconds between issuer and verifier — stale_signature errors point here first.
  • Reusing a single shared secret across many partners. Use distinct kids so revocation is per-partner.