API keys in a header are easy. They are also easy to steal. Once an attacker captures Authorization: Bearer sk_live_…, they can replay that request forever. HMAC-SHA256 request signing takes a different approach: the secret never travels on the wire. Each request carries a short-lived signature bound to the method, path, body, and time. That is the same family of design used by AWS Signature Version 4, Stripe webhooks, and GitHub.
This post walks through building that scheme from scratch—canonical request string, anti-replay timestamp, constant-time comparison—and when you should not reach for OAuth.
What HMAC actually proves
HMAC-SHA256 is a keyed hash. Given a shared secret and a message, it produces a 256-bit tag. Anyone with the secret can recompute the tag. Anyone without it cannot forge a valid one, and cannot recover the secret from the tag. A matching tag therefore proves two things at once:
- Authenticity — the sender held the secret.
- Integrity — the signed bytes were not altered.
A plain SHA-256 hash of the body is not enough. An attacker who can change the body can also recompute the hash. HMAC binds the hash to a key that never leaves the server.
Typical headers look like this:
Authorization: HMAC-SHA256 keyId="ak_live_9f3c", signature="a3f2b8c9…"
X-Timestamp: 1756455420
X-Nonce: 7c1e2a90-4b11-4d3e-9c22-0f8a1b2c3d4e
The keyId is public. It only tells the server which secret to load. The secret itself stays in a vault.
The canonical request string
Client and server must hash exactly the same bytes. If one side JSON-stringifies with spaces and the other does not, signatures never match. That normalized blob is the canonical request string.
A practical, language-agnostic format:
HTTP_METHOD + "\n"
+ PATH + "\n"
+ CANONICAL_QUERY + "\n"
+ CANONICAL_HEADERS + "\n"
+ TIMESTAMP + "\n"
+ NONCE + "\n"
+ HEX(SHA256(body))
Rules that prevent “it works in Postman but not in Python”:
- Method uppercase:
POST, notpost. - Path as received, percent-encoded consistently (
/items/test%20item). - Query sorted by key, then by value. Empty query is an empty line, not omitted.
- Headers lowercased names, trimmed values, sorted. Sign at least
hostandcontent-type. - Body is never raw JSON in the string. Hash the raw bytes with SHA-256, then hex-encode. An empty body is the hash of the empty string (
e3b0c442…). - Join with
\n. No trailing spaces. No BOM.
Signing the hash of the body, not the body itself, keeps the signed string small and avoids double-encoding fights across SDKs. AWS SigV4 does the same: hash the payload, then HMAC a string that includes that hash.
Then:
signature = HEX(HMAC-SHA256(secret, canonical_string))
Send keyId, timestamp, nonce, and signature. Never send the secret.
Anti-replay: timestamp (and often a nonce)
A valid signature on a captured payment request is still dangerous five hours later. Include the Unix timestamp inside the signed string so it cannot be rewritten without breaking the HMAC.
Server checks:
abs(now_utc - timestamp) <= 300 # five minutes
Five minutes is the common window (AWS, Stripe-style webhooks, many HMAC middleware defaults). Tighter windows need NTP on every client. Looser windows widen the replay gap.
A timestamp alone is not enough inside that window. An attacker can replay the same signed POST for 299 seconds. Add a nonce (UUID or 16+ random bytes), include it in the canonical string, and store used nonces until the window expires. Reject duplicates. For money-moving endpoints, also require an idempotency key and persist it longer than the replay window.
Clock skew cuts both ways: reject timestamps in the future as well as the past, or an attacker with a fast clock can pre-sign requests.
Constant-time comparison
This is the bug that looks harmless in code review:
if (computed === provided) { /* accept */ }
String equality short-circuits on the first differing byte. Over thousands of requests, response time leaks how many leading bytes were correct. That is a classic timing oracle against the signature.
Use a fixed-time compare on equal-length byte arrays:
- Node:
crypto.timingSafeEqual - Python:
hmac.compare_digest - .NET:
CryptographicOperations.FixedTimeEquals - Go:
hmac.Equal
If lengths differ, still run a dummy compare on a buffer of the expected length, then return false. Never parse the body or hit the database before you have a plausible signature—otherwise you turn auth into an unauthenticated CPU/IO amplifier.
Verify in this order: parse headers → load secret by keyId → check timestamp window → rebuild canonical string from raw bytes → HMAC → constant-time compare → then nonce store → then business logic.
Why OAuth is not always the right answer
OAuth 2.0 solves delegated authorization: an app acts on a user’s account with scoped, revocable tokens, usually through a browser consent screen. That is the right tool for “Login with Google” or a third-party integration that must not see the user’s password.
It is the wrong default for many APIs:
- Machine-to-machine jobs, IoT, cron, webhooks. There is no user, no redirect URI, and no refresh-token dance. A shared secret plus HMAC is smaller, offline-friendly, and has no authorization server to run.
- Request integrity. A Bearer token authenticates the caller, not this exact body. Steal the token and you can change the payload until expiry. HMAC binds method, path, headers, and body.
- Replay surface. Access tokens are reusable until they expire. HMAC signatures die with the timestamp window.
- Operational cost. OAuth means token endpoints, JWKS, rotation of signing keys, redirect allowlists, and PKCE for public clients. HMAC needs a secret per client and a 40-line verifier.
- Trust boundary. Inside one control plane (your services, your payment partner), a keyed MAC is enough. OAuth shines when you do not control the client and must constrain what it may do as a user.
Use OAuth (or mTLS, or DPoP) when clients are untrusted apps acting for humans. Use HMAC-SHA256 when two systems you operate need to prove “this exact request, now, from this key.” Many mature stacks use both: OAuth at the edge for users, HMAC between internal services and for inbound webhooks.
A short production checklist
- HMAC-SHA256 or stronger; never HMAC-MD5 or HMAC-SHA1.
- Secret ≥ 256 bits, random, stored hashed or in a KMS, rotated on a schedule and on leak.
- Canonical string documented as a spec, with golden test vectors in every SDK.
- Timestamp ±5 minutes, NTP everywhere, nonce cache for mutating methods.
- Constant-time compare only; no
==on hex strings. - HTTPS only. Signing does not replace TLS; it complements it.
- Log
keyIdand timestamp on failure, never the secret or the computed signature.
Signed requests are more work than dropping an API key in a header. That work is the difference between “someone sniffed one call” and “someone now owns the account.” Build the canonical string carefully, bind time into the MAC, compare in constant time—and reach for OAuth when you actually need delegation, not when you only need a trustworthy request.