Tools
Guides
On this page

August 9, 2026

HMAC and API request signing: a practical walkthrough with real numbers

You run TLS everywhere, so nobody can tamper with your traffic — and then one day an integration partner asks you to put an X-Signature header on every request, and you wonder why. The answer is that TLS and signing solve different problems, and the gap between them is real. This guide walks through the whole practice of API request signing with HMAC: why a keyed hash beats a bare hash, how to build a canonical string that both sides agree on, how timestamps and nonces stop replay attacks, and where JWT fits. Every digest in this article is real and reproducible — you can verify each one in the HMAC generator on this site, entirely in your browser.

Why sign requests at all when TLS exists#

TLS protects a connection. It guarantees that bytes traveling between two endpoints are not read or modified in transit. What it does not guarantee:

  • Who authored the request. TLS terminates at the server (or at a load balancer, or a CDN). Everything after the termination point — internal queues, logging pipelines, proxy hops — handles plain text again. A request that arrives at your application over plain HTTP from the reverse proxy could have come from anywhere inside the network.
  • That the request was not legitimately forwarded. Retries, webhook redeliveries, and message-queue replays all re-send traffic through trusted infrastructure. TLS is happy with all of them; only a signature with a timestamp can tell a deliberate re-send from a legitimate one.
  • Integrity after decryption. A bug in your logging middleware or a misconfigured proxy can mutate a body after TLS has done its job. A signature checked at the application layer catches that mutation, because the digest no longer matches.

So request signing is not a replacement for TLS — it is a second, application-layer seal that binds a specific caller (via the shared secret) to a specific payload (via the digest). That is why payment providers, cloud storage APIs, and webhook senders all mandate it.

What HMAC is, and why a bare hash is not enough#

HMAC — Hash-based Message Authentication Code, specified in RFC 2104 with SHA-2 test vectors in RFC 4231 — is a construction that mixes a secret key into a hash function. Instead of SHA-256(message), you compute HMAC-SHA-256(key, message). The key is folded into two padded rounds of hashing, which matters mathematically (it defeats length-extension attacks that break the naive hash(key || message) scheme), but the operational consequence is simpler:

Anyone can compute a plain hash; only key holders can compute an HMAC.

That single sentence is the whole case. Watch it with real numbers. Take this API body:

{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}

An attacker who wants to change the amount to 990000 can recompute the plain SHA-256 of the tampered body just as easily as you can:

sha256(original body)  = 4a261f114b99584887c583817ce67ca7e74b23860b8775c332cfcf008fe3c409
sha256(tampered body)  = 2b3f929865ee7557dd6c67a619ab0aab409627b27b1a8e40d7130e44cf4a5665

Both digests look perfectly authoritative. A checksum sent alongside the message proves nothing, because the attacker simply replaces both. Now the same tampering under HMAC with a secret the attacker does not hold — even though they can still see both digests:

hmac(original body)    = f977d47dd3b47ab9cb8ad3074d1ffa6341d4c136a2c919edffe4b5fd2dc9ca94
hmac(tampered body)    = 0101935e37507a084f089bcbb9ae4057980a4ac8c4a2afef3ec8bfd1c17ef25e

The attacker can produce the second line only by running HMAC with the real key — which they cannot do. The tampered digest cannot be made to match anything the receiver expects. All four values above were computed with the key whsec_demo_4f9a2c8e17b0d5f3; paste the body and the key into the HMAC tool with SHA-256 and hex selected, and the first HMAC line reproduces exactly. That is your ground truth for the rest of this guide.

One boundary to hold onto: HMAC authenticates, it does not encrypt. The body above is still plain text. If a request also needs confidentiality, that is a separate mechanism.

Building a signed request, step by step#

Real-world signing never hashes the body alone. To make the signature meaningful, it must cover everything the receiver will act on. The standard pattern is a canonical string: a deterministic serialization of the request’s essential parts, joined in an agreed order.

Suppose the API you are calling specifies: sign METHOD \n PATH \n BODY \n TIMESTAMP using HMAC-SHA-256, hex-encoded, and send the signature, timestamp, and your access ID in headers. For a POST to https://api.example.com/api/v1/orders with the body above and a Unix timestamp of 1754742000, the canonical string is:

POST
/api/v1/orders
{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}
1754742000

And the HMAC-SHA-256 of that string with our demo key is:

c425bde754d3b757ad49e21011adaf89f25425ab9938768bade29f2a91aca256

The request then looks like this:

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Api-Id: ak_demo_7f21
X-Timestamp: 1754742000
X-Signature: c425bde754d3b757ad49e21011adaf89f25425ab9938768bade29f2a91aca256

{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}

The signing procedure, generalized:

  1. Build the canonical string exactly as the API documents it. Method, path, query string (sorted by key, if included), headers (lowercased names, trimmed values), body, timestamp — whatever the spec lists, in the listed order. This is a protocol, not a suggestion; both sides must derive byte-identical strings or nothing works.
  2. HMAC the canonical string with the shared secret, using the specified hash (SHA-256 unless told otherwise) and the specified output encoding (hex and base64 are both common; they encode the same bytes).
  3. Send the signature plus everything needed to recompute it — timestamp, key identifier, and the algorithm if the API accepts multiple. A signature the receiver cannot re-derive is decoration.
  4. On the receiver: recompute, then compare in constant time. Rebuild the canonical string from the received request, HMAC it, and compare digests with a constant-time function (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Ordinary string comparison leaks how many leading bytes matched, and on a high-traffic endpoint that leakage is exploitable.
  5. Only then parse and act. Verification precedes parsing, always — parse-after-verify, not verify-after-parse.

Stopping replay: timestamps and nonces#

A captured, validly signed request is a validly signed request — the signature alone cannot tell the receiver that the sender meant to send it now. That is what the timestamp is for. The receiver checks the X-Timestamp against server time and rejects anything outside a tolerance window, typically five minutes in each direction, to absorb clock skew. Inside the window, though, a captured request could still be re-sent up to five minutes later; to close that gap you add a nonce (number used once):

  1. The sender includes a unique value — a random UUID works — in both the canonical string and a header.
  2. The receiver stores each accepted nonce with its timestamp, and rejects any nonce it has seen within the window.
  3. Nonces older than the window are forgotten, since the timestamp check already rejects the requests that carried them.

The combination is airtight within its assumptions: a replayed request either carries a stale timestamp (rejected by the clock) or a reused nonce (rejected by the cache). Webhook senders use the same idea in a lighter form — Stripe-style signatures include a timestamp in the signed material, so verifiers hash "${t}.${body}" and reject old t.

One operational note: the window is a clock-skew budget. If your signing server drifts more than a couple of minutes, you will chase mysterious signature failures that are really time failures. Monitor clock sync on anything that signs.

Worked example: verifying a webhook-style signature#

Webhooks are the most common place developers first implement HMAC verification, on the receiving side. Say a provider sends events to https://api.example.com/hooks with a header X-Signature: sha256=<hex>, signing the exact bytes of the body with the shared secret. A minimal, correct Node handler:

import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hooks", express.raw({ type: "*/*" }), (req, res) => {
  const expected =
    "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(req.body)               // raw bytes, not re-serialized JSON
      .digest("hex");

  const got = req.get("x-signature") ?? "";
  const ok = got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected));

  if (!ok) return res.status(400).end("bad signature");
  // signature verified — now parse and act
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(204).end();
});

The load-bearing detail is express.raw: the signature covers the exact bytes the provider sent. If your framework parses JSON and re-serializes it, key order and whitespace may differ and the digest will not match. To feel this failure mode with real numbers, take our demo body and its signature f977d47d…ca94, then pretty-print the same JSON with two-space indentation and sign that instead:

hmac(pretty-printed body) = cef0b1baf7e47c11aef9fdadaacf7071f729fdb9461d6668d92ffdff7bd6ef4e

Same JSON value, same key, different bytes — completely different digest. This whitespace trap is the single most common cause of “signature mismatch” reports, ahead of every other cause combined. When a receiver rejects your signature, compare the exact bytes you signed against the exact bytes received, character for character, before you touch anything else.

For checking your own implementation end to end, use published test vectors. RFC 4231’s first test case: a 20-byte key of 0x0b repeated, message Hi There, HMAC-SHA-256 hex output:

b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7

If your code produces that digest for those inputs, your HMAC layer is correct and any mismatch is in how you build the canonical string or encode the output. You can confirm the digest in the HMAC generator — it uses the browser’s native Web Crypto, the same primitive production code uses.

How this differs from JWT#

Both HMAC and JWT show up in “authenticate this request” conversations, but they occupy different positions:

  • HMAC signs a message you already have. The request and its signature travel together, and the receiver recomputes. Nothing is self-describing: the receiver needs the shared secret and the canonical-string spec. It is ideal for point-to-point agreements — you and your payment provider, you and your storage provider — where both ends are configured by the same people.
  • A JWT is a self-contained token carrying claims. It bundles its own metadata (issuer, subject, expiry) and a signature over all of it. Notably, a JWT signed with the HS256 algorithm contains an HMAC-SHA-256 — the same primitive, wrapped in a structured envelope. JWTs shine when a token must pass through systems that did not each receive a shared secret, or when the receiver needs expiry and scopes embedded in the credential rather than inferred from context.

A practical split: use raw HMAC signing for server-to-server request and webhook authentication where both parties share configuration; use JWT when a bearer token must be issued once and validated by several independent parties. If you want to see the inside of a token, the JWT decoder on this site takes one apart, locally in your browser.

Common mistakes, ranked by how often they burn someone#

  • Signing parsed-and-re-serialized JSON. Covered above. Sign the exact bytes on the wire.
  • Non-constant-time comparison. == on digests leaks prefix-match length. Use the timing-safe compare your platform provides.
  • A guessable secret. HMAC’s security reduces to the key. whsec_demo_4f9a2c8e17b0d5f3 is 26 characters of demo material; production secrets should be 32+ random bytes from a cryptographic source, unique per environment, rotated on a schedule with an overlap window where both old and new signatures are accepted.
  • Leaving the timestamp out of the signed material. If the timestamp rides in a header but is not part of the canonical string, attackers can freely rewrite it, and your anti-replay window is fiction.
  • Treating a valid signature as authorization to act. A signature proves the message is intact and from the key holder. It does not prove the account has funds, the refund is still pending, or the event is still true. For high-value actions, confirm state through the API before acting.

FAQ#

Is HMAC encryption?#

No. It is authentication. The message stays in plain text; what the digest provides is proof that the bytes are unmodified and originated from a key holder. Confidentiality requires encryption on top, or an authenticated-encryption mode such as AES-GCM.

Hex or base64 for the signature header?#

Both encode the same digest bytes. Hex is longer (64 characters for SHA-256) but case-insensitive tooling is common and it reads cleanly in logs; base64 is shorter (44 characters) but is case-sensitive and occasionally gets mangled by URL-safe versus standard variants. Follow whatever the API documents; when you control both ends, hex is the safer default.

Why does my signature match locally but fail against the server?#

In order of likelihood: the canonical string differs (whitespace, key order, path with or without query, header casing), the encoding differs (hex versus base64, or base64url), the timestamp is outside the window, or the secret has trailing whitespace or a different environment’s value. Reproduce with the RFC 4231 vector first to eliminate your HMAC layer, then diff the canonical strings byte for byte.

Can I use one signing secret for everything?#

No. Separate secrets per environment (test, staging, production) at minimum, and per counterparty where possible. A secret shared with a third party is a secret that third party’s incident response now has to protect. Rotation with a dual-signature acceptance window — verify against old and new for a transition period — avoids downtime.

What hash should I use?#

SHA-256. It is the industry default for request signing, fast on 64-bit hardware, and a 256-bit digest is far beyond any brute-force horizon. Move to SHA-512 only when a counterparty mandates it (it is occasionally faster on 32-bit platforms). SHA-1 appears only in legacy integrations.

Summary#

Signing fills the gap TLS leaves open: it binds a caller’s identity to the exact bytes of a request, in a way that survives proxy hops, retries, and logging pipelines, and that an attacker without the key cannot forge. The practice reduces to a short checklist — build the canonical string exactly as specified, HMAC it with a strong per-environment secret, compare in constant time, enforce the timestamp window and nonce cache on the receiving side, and verify before parsing. The RFC 4231 vectors are your ground truth when something misbehaves.

Work through the examples on this site: compute every digest in this guide with the HMAC generator, compare plain digests side by side with the hash calculator, and if you also carry JWTs, take one apart with the JWT decoder.

← All guides