Tools
Guides
On this page

August 9, 2026

API request signing: the complete nonce + timestamp + HMAC workflow

TLS protects data on the wire, but it does not protect your API from a request that is perfectly validly encrypted and completely forged. If your API accepts any well-formed request from any holder of a transport-level connection, then anyone who discovers an endpoint can call it. API keys sent as bare headers are a step up, but a static credential inside the request is a replay waiting to happen: sniff it once (from a log, a proxy, a misconfigured analytics pipeline) and it works forever.

Request signing closes both gaps at once. The client proves possession of a secret without transmitting it, and the server can reject requests that are copies of earlier ones. This guide builds the full workflow — canonical string, HMAC, timestamp window, nonce cache — and then runs a complete worked example with real digests you can verify in the browser with the HMAC generator and turn into runnable code with the curl converter.

Why sign requests at all?#

Three properties, in increasing order of ambition:

  1. Authentication without transmitting the secret. The signature is derived from the secret and the request; the secret itself never crosses the wire. An attacker who captures full traffic learns the request and the signature, but neither lets them sign a different request.
  2. Integrity of the whole request. The signature covers method, path, and body. Tamper with any byte and the signature no longer matches — unlike a bare API key, which happily rides along with modified payloads.
  3. Freshness (anti-replay). By mixing in a timestamp and a one-time nonce, the server can verify the request was made now, not replayed from a capture. This is the property most self-built schemes quietly lack.

Compare the alternatives: a static Authorization: <key> header gives you none of the three; mTLS gives strong client identity but requires certificate distribution and is awkward per-request; a JWT gives you expirable, self-describing credentials but is a bearer token — whoever holds it can use it until it expires. Request signing is the pragmatic middle: strong per-request guarantees, no PKI, one shared secret per client.

The three building blocks#

HMAC: keyed hashing, done by the platform#

HMAC (Hash-based Message Authentication Code, specified in RFC 2104) runs a hash function like SHA-256 twice, folding a secret key into both rounds. Without the key, no one can compute the digest — so the digest doubles as proof of possession. Use your platform’s implementation and never hand-roll it: crypto.createHmac in Node, hmac.new in Python, crypto.subtle.sign with HMAC in browsers. The key should be at least 32 random bytes from a CSPRNG, hex-encoded for storage; a human-chosen password as a signing key falls to dictionary attacks in seconds. If you need a quick random secret, the random token generator produces CSPRNG output locally.

The timestamp: a freshness window#

Every signed request carries the current Unix time in seconds. The server checks:

|now - requestTimestamp| <= ALLOWED_SKEW

with a typical skew window of 300 seconds (5 minutes). The window absorbs two realities: client clocks drift, and requests legitimately queue for a few seconds behind retries or slow networks. Too tight and you reject valid traffic; too loose and you give an attacker a longer replay menu. Five minutes is the de facto industry default.

Crucially, the timestamp must be part of the signed payload. A timestamp in an unsigned header protects nothing — an attacker just updates it. One important subtlety: always use UTC (or raw epoch seconds) for the timestamp. A local-time timestamp embeds a hidden offset that makes server-side comparison nondeterministic across DST; epoch seconds have no time zone at all, which is exactly what you want. If you need to double-check what an epoch value means in human terms, the timestamp converter renders it side by side with UTC and your local zone.

The nonce: one-time use, cached#

The timestamp window alone is not enough: within five minutes, a captured request can still be replayed successfully. The nonce (“number used once”) closes that hole. Every request gets a unique random value; the server records each nonce it has seen and rejects any second use.

To keep the cache finite, scope it to the window: store nonce -> timestamp and evict entries whose timestamp is older than the window. Once a nonce’s timestamp has fallen out of the window, a request using it fails the timestamp check anyway, so the nonce entry is dead weight. Twelve bytes of CSPRNG output, hex-encoded (24 characters), is plenty. Note that a nonce only adds protection against replay; it is not a substitute for the signature, and an unsigned nonce can simply be swapped out by an attacker.

The canonical string: where implementations actually break#

The client and server must hash exactly the same bytes. That agreement is spelled out in a canonical string — a deterministic serialization of the request. A typical layout:

METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY

Every design decision in the canonical string is a decision both sides must make identically:

  • Method uppercase (POST), because some clients send lowercase.
  • Path only, without host, port, or query string — or with the query string sorted by key, if you sign queries. Sorting matters: the same parameters can arrive in any order on the wire.
  • Timestamp and nonce exactly as they will appear in headers, so bytes on the wire and bytes in the string agree.
  • Body as the exact bytes sent — not re-serialized JSON. Key order, whitespace, and unicode escaping (é vs é) all change the digest. This single rule causes the majority of “signature mismatch” support tickets: the client signs compact JSON, an SDK re-serializes it with spaces, and the digests diverge.

Empty body? Sign the empty string — but make that explicit in your docs, and make both sides do it the same way.

Common canonical-string bugs#

  • Re-serializing the body instead of signing the raw bytes.
  • Signing /v1/orders on the client while a gateway rewrites the path to /api/v1/orders before the app sees it.
  • Including headers whose names get normalized by proxies (X-Foo vs x-foo).
  • Newline as separator on one side, nothing or & on the other.
  • Default unicode normalization on one platform but not the other (NFC vs NFD).

Document the canonical string to the byte, ship reference vectors in your docs, and you avoid all of them.

Server-side verification, step by step#

On receiving a request with headers X-Timestamp, X-Nonce, X-Signature:

  1. Check the timestamp window first. Parse X-Timestamp; if |now - ts| > 300, reject with 401 before doing any crypto work. This is also your cheapest defense — reject expired requests without touching the HMAC or the nonce cache.
  2. Check nonce freshness. Look the nonce up in the cache. If present (and inside the window), reject — it is a replay. Otherwise reserve it atomically (the atomicity matters under concurrency; use a SETNX-style primitive or a database unique constraint).
  3. Rebuild the canonical string from the raw bytes you received: real method, real path, header values verbatim, raw body.
  4. Recompute the HMAC with the client’s secret (looked up by client ID, which should be its own unsigned header) and compare with X-Signature using a constant-time comparison — timingSafeEqual in Node, hmac.compare_digest in Python. Plain == leaks how many leading bytes matched.
  5. Reject on any failure with a generic error. Log the failure reason server-side, never to the client — detailed errors (“timestamp skew 312s”) are a free oracle for an attacker tuning a replay.

Note the deliberate order: cheap checks (timestamp, nonce) run before the expensive one (HMAC), and everything that identifies which check failed stays in your logs.

Worked example: signing a real request#

Enough theory — let us sign an actual request end to end. The client wants to call POST /v1/orders with this body:

{"amount":42,"currency":"EUR"}

Parameters (a test secret, never production):

secret     = 8f4a2b9c1e7d5f3a6b8c0d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3
timestamp  = 1723455667          (2024-08-12T09:41:07Z)
nonce      = a7f3c9e2

The canonical string, with \n separators — method, path, timestamp, nonce, then the exact body bytes:

POST
/v1/orders
1723455667
a7f3c9e2
{"amount":42,"currency":"EUR"}

HMAC-SHA-256 over that string with the secret above, hex-encoded:

bd6b2edbf699ac91b464db66a9f1f37a09332093e24f2c6b523971870ca15938

The client sends the request with the signature and parameters in headers:

curl -X POST "https://api.example.com/v1/orders" \
  -H "X-Client-Id: shop-12345" \
  -H "X-Timestamp: 1723455667" \
  -H "X-Nonce: a7f3c9e2" \
  -H "X-Signature: bd6b2edbf699ac91b464db66a9f1f37a09332093e24f2c6b523971870ca15938" \
  -H "Content-Type: application/json" \
  -d '{"amount":42,"currency":"EUR"}'

Now watch the tamper detection work. Change one character of the body — 42 becomes 4200 — keeping everything else, and the correct signature for the modified request is:

054871423a739b26d16dd4a2dd5ccc8138d0ff9827490dc5e5363c2003bc8ca0

The attacker does not have the secret, so they cannot compute that value. The original signature no longer matches, and the server rejects the request. That is the entire security argument in one diff.

Verify it yourself. Open the HMAC generator, set the algorithm to SHA-256 and output to hex, paste the secret as the key, and paste the five-line canonical string (with real newlines) as the message. You will get bd6b2edb… back — the exact digest above, computed locally in your browser by the same Web Crypto primitive production code uses. Then paste the curl command into the curl converter and get the same request as fetch, axios, Python requests, Go, Java, or HTTPie — a ready-made client snippet for your docs or your test suite.

A minimal client-side signer in Node:

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

function signRequest({ method, path, body, secret }) {
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = randomBytes(12).toString("hex").slice(0, 8);
  const canonical = [method.toUpperCase(), path, timestamp, nonce, body].join("\n");
  const signature = createHmac("sha256", secret)
    .update(canonical)
    .digest("hex");
  return { timestamp, nonce, signature };
}

The server mirrors steps 1-5 from the previous section over the same canonical string.

Variations you will meet in the wild#

  • Signed headers. Real schemes (cloud provider APIs, payment gateways) often include a list of signed header names in the canonical string, so authentication headers and idempotency keys are covered too. More moving parts, same principles.
  • Multiple hash algorithms. The signature header may carry an algorithm prefix (sha256=…) so you can migrate to SHA-512 or SHA-3 without a flag day. Verify against any algorithm you still accept.
  • Key rotation. Issue a second secret, sign with the new one during an overlap window while accepting both, then retire the old. A X-Key-Version header lets the server pick the right secret without guessing.
  • HMAC vs JWT. A JWT with HS256 contains an HMAC — but a JWT is a bearer token: whoever holds it can present it until expiry, and its signature covers the token, not the per-request body. Request signing binds each individual request. You can decode and inspect either with the JWT decoder. Many systems use both: JWT for the login session, per-request HMAC for high-value operations.
  • Replay windows for webhooks. The same timestamp mechanism protects inbound webhooks — the provider signs timestamp.body and you enforce the 5-minute window, exactly as described in our HMAC guide for webhook verification.

FAQ#

Why not just put the API key in the Authorization header?#

Because a static key in the request is replayable forever and proves nothing about the rest of the request. Any log line, proxy cache, or browser extension that captures the header grants permanent access. Signing keeps the secret on the client, covers the body, and expires requests within minutes. If you keep a static key, at minimum scope it per client and rotate aggressively.

What timestamp precision — seconds or milliseconds?#

Use seconds. The skew window is minutes, so sub-second precision adds nothing, and seconds match the Unix tradition most server clocks speak natively. If you do use milliseconds, fix it in the spec — a server comparing a milliseconds value against a seconds threshold rejects everything or nothing, which is at least easy to spot.

How long should the skew window be?#

Five minutes is the common default. The floor is your worst-case end-to-end latency plus client clock drift (NTP-synced clients drift by milliseconds; broken clocks drift by minutes). If your clients are browsers on unknown hardware, stay at five minutes and lean on the nonce for replay protection — that is precisely what the nonce is for.

Does the nonce cache need to survive restarts?#

For strict one-time semantics, yes — an in-memory cache forgets nonces on deploy, reopening a (five-minute) replay window at every restart. A Redis-style store with TTL, or a database table with a unique constraint, survives restarts and scales across replicas. If a brief window during deploys is acceptable for your threat model, in-memory with TTL is a defensible simplification — say so explicitly in your design doc.

The signature mismatch error — what do I check first?#

Almost always the canonical string. Compare, byte for byte: raw body vs re-serialized JSON, path as sent vs path after gateway rewrites, header name casing, separator newlines, unicode escapes. The fastest diagnosis is to have the client print the exact canonical string it signed and the server print the exact string it verified, then diff them.

Can I use the same secret for multiple environments?#

No. Separate secrets for test and production, so a leaked test key is a nuisance rather than an incident. Generate each independently from a CSPRNG, and never reuse a secret across clients — the whole point of per-client keys is revocation without collateral damage.

Summary and tools#

The workflow in one paragraph: serialize the request into a documented canonical string, mix in a fresh timestamp and nonce, HMAC the string with a per-client secret, send the signature in headers, and on the server check the timestamp window, enforce nonce one-time use, rebuild the canonical string from raw bytes, and compare in constant time. The cryptography is one line; everything that breaks in practice is the canonical string and the operational details — key storage, rotation, nonce cache, clock drift.

Build and verify it with these tools:

  • HMAC generator — reproduce every digest in this guide in your browser (SHA-256, hex or base64), and cross-check your own signer against the same Web Crypto primitive production uses.
  • curl converter — turn the signed curl command into fetch, axios, Python, Go, Java, or HTTPie for docs and tests.
  • JWT decoder — inspect bearer tokens and their exp claims when you combine session JWTs with per-request signing.
  • Random token generator — generate CSPRNG secrets and nonces locally, nothing uploaded.

← All guides