HMAC Request Signing

Sign the request itself so the receiver can prove who sent it, that nothing was altered, and that it is not a replay.

A bearer credential proves you have the secret by handing it over. An HMAC signature proves you have it without sending it: both sides hold a shared secret, the sender signs a canonical description of the request, and the receiver recomputes the same signature.

What gets signed

The signature must cover everything that matters, in an order both sides agree on:

  • the HTTP method and path (and query, sorted)
  • a hash of the body
  • a timestamp
  • a nonce, if you want replay protection stronger than the timestamp window

That agreed serialisation is called the canonical request. Almost every HMAC bug is a canonicalisation mismatch — a trailing slash, a re-ordered query parameter, a body that a middleware re-serialised before you hashed it.

What it buys you

  • Integrity — change one byte of the body and the signature fails.
  • Authenticity — only a holder of the secret can produce it.
  • Replay resistance — a stale timestamp is rejected; a seen nonce is rejected.
  • The secret never travels, so an intercepted request cannot be reused against another endpoint.

Where you meet it

AWS SigV4 signs every AWS API call this way. Stripe, GitHub, Shopify, Slack and most payment providers sign their outgoing webhooks with HMAC-SHA256 so you can verify the delivery really came from them. If you send webhooks, this is the scheme to use.

The rules that matter

  1. Verify against the raw body bytes, before any JSON parsing or re-serialisation.
  2. Compare in constant time. A plain === on hex strings leaks the signature byte by byte.
  3. Enforce a tolerance window (five minutes is typical) on the timestamp, and include the timestamp in the signed payload so it cannot be edited.
  4. Support two secrets during rotation so the receiver can accept either.

Example

Example · bash
# The canonical string both sides build
timestamp=1754312400
body='{"event":"order.paid","id":"ord_18"}'
payload="v1:${timestamp}:${body}"

# The sender signs it
signature=$(printf '%s' "$payload" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
  | xxd -p -c 256)

curl -X POST https://abc.com/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-Signature-Timestamp: ${timestamp}" \
  -H "X-Signature: v1=${signature}" \
  -d "$body"

# The receiver rebuilds "v1:timestamp:body" from the RAW body and compares.

When to use it

  • A payment provider signs every webhook so the shop's server can reject forged 'payment succeeded' calls from anyone who guesses the endpoint URL.
  • An AWS SDK signs each API call with SigV4, which is why an intercepted request cannot be replayed against a different bucket or after five minutes.
  • A B2B partner integration signs requests so that even inside a shared corporate network, a compromised proxy cannot tamper with order amounts in transit.

More examples

Verifying a signed webhook (Express, raw body)

The timestamp is inside the signed payload, so an attacker replaying an old capture cannot simply update the header to slip inside the tolerance window.

Example · javascript
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';

const app = express();
const TOLERANCE_S = 300;                 // 5 minutes
const SECRETS = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD]
  .filter(Boolean);                      // accept both during rotation

// CRITICAL: raw body, not express.json(), or the bytes change under you.
app.post('/webhooks/payments',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ts  = Number(req.get('x-signature-timestamp'));
    const sig = (req.get('x-signature') || '').replace(/^v1=/, '');

    if (!ts || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) {
      return res.status(400).json({ error: 'stale_timestamp' });
    }

    const payload = `v1:${ts}:${req.body.toString('utf8')}`;
    const given = Buffer.from(sig, 'hex');

    const ok = SECRETS.some((secret) => {
      const expected = createHmac('sha256', secret).update(payload).digest();
      return given.length === expected.length && timingSafeEqual(given, expected);
    });

    if (!ok) return res.status(401).json({ error: 'bad_signature' });

    const event = JSON.parse(req.body.toString('utf8'));   // parse AFTER verifying
    handle(event);
    res.sendStatus(204);
  });

Signing an outgoing request (canonical form)

Publish the canonicalisation rules as part of your API contract. 'It works in our SDK' is not a specification, and integrators will build their own clients.

Example · php
<?php
// Sign method + path + sorted query + body hash + timestamp.
function signRequest(string $method, string $path, array $query,
                     string $body, string $secret): array
{
    ksort($query);                                  // order must be deterministic
    $canonicalQuery = http_build_query($query);
    $timestamp      = time();
    $bodyHash       = hash('sha256', $body);

    $canonical = implode("\n", [
        strtoupper($method),
        $path,
        $canonicalQuery,
        $bodyHash,
        (string) $timestamp,
    ]);

    return [
        'X-Signature-Timestamp' => (string) $timestamp,
        'X-Signature'           => 'v1=' . hash_hmac('sha256', $canonical, $secret),
    ];
}

// The receiver rebuilds the identical string. If either side normalises the
// path differently (trailing slash, %2F vs /), every signature fails — which is
// why the canonical form must be written down in the API docs, byte for byte.

Discussion

  • Be the first to comment on this lesson.