← Back to Logs

How HMAC Request Signing Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)

When an API provider says "sign every request", what they usually mean is not "encrypt the request" and not "add a JWT somewhere". They mean: take the exact HTTP request, reduce it to one unambiguous byte sequence, run a keyed message authentication code over that sequence, send the resulting tag alongside the request, and make the server reject anything whose bytes, timestamp, or replay state do not match.

That idea sounds small. In code, it is often one call to HMAC-SHA-256(secret, string_to_sign). In production, it is an entire protocol. The hard part is rarely the HMAC primitive itself. The hard part is deciding exactly which bytes are protected, making the client and server serialise those bytes identically, surviving proxies and load balancers, and stopping a captured request from being replayed five minutes later against the payments endpoint.

This is why HMAC request signing has such a mixed reputation. When it is done carefully, it gives you a boring, robust way to authenticate machine-to-machine requests and webhooks. When it is done carelessly, it creates a fragile scheme that fails every time a framework normalises a path differently or a reverse proxy rewrites Host on the way in. The cryptography is the easy part. The protocol design is the work.

This article explains the mechanism from the bottom up. We will start with HMAC itself, then build a real signed HTTP request, then verify it on the server, then deal with replay protection, key rotation, proxy interference, and the operational edge cases that usually break these systems. The examples use European names, euro-denominated transfers, and an api.example.eu style endpoint, but the design is the same whether you are building an exchange API in Frankfurt, a webhook consumer in Amsterdam, or an internal control-plane service in Dublin.

Signed Requests Exist Because TLS Is Not the Whole Story

People sometimes ask why request signing exists when HTTPS already gives you integrity and authentication. The answer is that TLS and application-layer request signing solve related but different problems.

TLS protects the connection between two network endpoints. If Sofia's client opens a TLS session to api.example.eu, the bytes travelling over that connection are encrypted and authenticated in transit. An on-path attacker cannot flip amount=125.00 into amount=9125.00 without breaking the TLS record authentication.

That is excellent, but it does not cover every real deployment pattern.

First, many APIs are not browser traffic. They are server-to-server calls, mobile uploads through gateways, or webhook deliveries from an external platform to your service. In those cases you often want the request itself to carry a proof of origin that survives beyond one TCP connection. A webhook consumer that receives a POST from GitHub, Stripe, or a payment processor wants to verify that the sender knew a shared secret, not merely that the sender completed a TLS handshake.

Second, TLS authenticates the endpoint that terminated the connection, not necessarily the application component you care about. In a modern deployment, TLS may terminate at a CDN edge, API gateway, ingress controller, or service mesh proxy before the request is forwarded internally. Internal forwarding may still be secure, but the trust boundary is now larger than one process. Signed requests let the application verify that the method, path, headers, and body it is about to act on are the same ones the authorised client signed.

Third, application signing gives you explicit coverage control. TLS authenticates all bytes on the connection, but it does not tell your business logic which fields matter semantically. A request-signing protocol can say, for example, that method, canonical path, canonical query, host, content-type, x-date, and the body hash are all protected, while a tracing header is not. That matters when proxies legitimately add headers or when you need deterministic logging and replay detection.

Fourth, signed requests are often used across asynchronous boundaries. A payment platform may generate a signed callback, queue it, retry it, and deliver it later. The receiver wants to verify the callback from the message itself plus a timestamp and nonce, not from a connection-level channel state that disappeared long ago.

So the right mental model is:

  • TLS secures the transport hop.
  • HMAC request signing secures a chosen application-level representation of the request.

They are complementary. In a sensible system you almost always use both.

HMAC Is a Keyed MAC, Not "Hash the Secret and Message Somehow"

HMAC stands for Hash-based Message Authentication Code. The construction was formalised in RFC 2104 by Hugo Krawczyk, Mihir Bellare, and Ran Canetti in 1997. It takes a cryptographic hash function such as SHA-256 and turns it into a keyed authentication primitive.

The key point is that HMAC is a construction, not an informal habit. It is not "stick the secret next to the message and hash the result". That naive idea causes real problems.

Suppose you tried to authenticate messages with:

bad_mac = SHA256(secret || message)

If the underlying hash is a Merkle-Damgard design such as MD5, SHA-1, or SHA-256, this opens the door to length-extension attacks. An attacker who knows SHA256(secret || message) and the length of secret || message can often compute a valid hash for:

SHA256(secret || message || padding || attacker_controlled_suffix)

without knowing the secret. That is unacceptable for a MAC. You do not want a recipient to accept an appended message fragment just because the attacker understood how the compression function chaining state works.

HMAC avoids that class of problem by wrapping the hash twice with two different key-derived pads.

For a hash with block size B bytes, HMAC first normalises the key:

  • if the key is longer than B, hash it down first
  • if it is shorter than B, pad it with zeros to B

Call that block-sized key K'. Then HMAC computes:

HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m))

where:

  • ipad is byte 0x36 repeated B times
  • opad is byte 0x5c repeated B times

For SHA-256, B = 64, so both pads are 64 bytes long.

The practical consequences are important.

HMAC treats the key as separate cryptographic input

The secret is not just another string concatenated next to attacker-controlled data. The construction deliberately mixes the secret into two independent hash invocations.

HMAC survives hash collision drama better than naive keyed hashing

MD5 and SHA-1 are broken for collision resistance, but HMAC-MD5 and HMAC-SHA-1 remained usable longer in some legacy protocols because the attacker's job in HMAC is not the same as finding an ordinary collision. That said, for new systems you should use HMAC-SHA-256 or better and avoid legacy algorithms unless an old standard forces them.

HMAC output is a MAC tag, not encryption

Anyone who sees the request still sees the request. The HMAC proves that someone who knew the key authenticated those bytes. It does not hide the bytes. If the request body contains sensitive data, you still need TLS and sensible storage rules.

Verification must be constant time

Once you compute a candidate tag on the server, you must compare it with the presented tag using a constant-time function such as Node's crypto.timingSafeEqual, Python's hmac.compare_digest, or Go's hmac.Equal. Byte-by-byte early-return equality checks turn MAC verification into a timing oracle.

So HMAC gives you a solid cryptographic primitive. But a signed request is still not defined until you answer a more awkward question: what, exactly, is the message m?

The Real Protocol Starts When You Define the Canonical Request

HTTP is not a single byte string in the way many people assume. There are multiple layers of representation:

  • a URL may be percent-encoded in several equivalent-looking ways
  • query parameters may arrive in different orders
  • header names are case-insensitive but values are not always
  • some frameworks decode and re-encode paths before your handler sees them
  • JSON bodies can be serialised with different spacing or key order while representing the same object
  • proxies may append or rewrite headers on the way through

If the client signs one representation and the server verifies another, signatures break. Worse, if your scheme forgets to cover a field that materially changes the meaning of the request, the signature may continue to verify while the business operation changes.

This is why every serious request-signing scheme starts with a canonical request definition: an exact rule for turning method, path, query, chosen headers, and body into a byte sequence.

A typical canonical request includes:

  1. HTTP method in uppercase
  2. canonical path
  3. canonical query string
  4. canonical header block
  5. a semicolon-separated signed-header list
  6. a body hash

For example, imagine this request from a merchant backend in Berlin:

POST /v1/transfers?from=wallet-eur&to=vendor-berlin HTTP/1.1
Host: api.example.eu
Content-Type: application/json; charset=utf-8
X-Date: 20260511T101530Z
X-Nonce: 8f1c2d44b7
X-Content-SHA256: eda189e1131810782d050b89195810fdf9a293ddb7fe5660f65447658643d22e
Authorization: HMAC-SHA256 Credential=client-berlin-7, SignedHeaders=content-type;host;x-content-sha256;x-date;x-nonce, Signature=2009fe0a4bf1d312942daf360445a9aad4368ecdf5deed09eb4c74f5a7916d9f
 
{"amount":"125.00","currency":"EUR","reference":"invoice-1842"}

One possible canonical form is:

POST
/v1/transfers
from=wallet-eur&to=vendor-berlin
content-type:application/json; charset=utf-8
host:api.example.eu
x-content-sha256:eda189e1131810782d050b89195810fdf9a293ddb7fe5660f65447658643d22e
x-date:20260511T101530Z
x-nonce:8f1c2d44b7
 
content-type;host;x-content-sha256;x-date;x-nonce
eda189e1131810782d050b89195810fdf9a293ddb7fe5660f65447658643d22e

Several details are doing real work here.

Query parameters are sorted and encoded deterministically

If the client signs to=vendor-berlin&from=wallet-eur and the server sorts them as from=wallet-eur&to=vendor-berlin, the signature fails unless both sides agreed on sorting first. If duplicate keys are allowed, the order rule must say how to sort ties as well.

Header names are normalised to lowercase

HTTP header names are case-insensitive. Host, host, and HOST mean the same thing on the wire. The canonicalisation rule must pick one representation.

Header whitespace is controlled

Do you trim leading spaces after the colon? Collapse repeated internal spaces? Preserve comma spacing exactly? You need one answer. Ambiguity here is how signatures turn into support tickets.

The body is represented by a hash, not by reparsed JSON

This is critical. If the server parses JSON into an object and then serialises it again before verification, it may produce a different byte sequence from the one the client sent. Key order, spacing, Unicode escaping, and number formatting can all change. Sign the exact raw body bytes or their exact hash, not some re-rendered semantic object.

A body hash also keeps the canonical string compact and consistent across payload sizes. For an empty body, most schemes use the SHA-256 hash of the empty string:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Once you understand this, you also understand why signed-request bugs cluster around normalisation. The HMAC primitive is fine. The canonical bytes were not.

The Client-Side Signing Pipeline Is Mechanical but Unforgiving

A signing client usually follows the same sequence every time.

1. Decide which fields are covered

At minimum, cover every field that changes the meaning of the request. For most APIs that means method, path, query, selected headers, and body hash. If host is not covered, a signed request might be replayed against another virtual host. If the body hash is not covered, the body might be swapped while the signature still verifies.

2. Produce the canonical request

The client serialises those fields according to the exact protocol rules. The output must be deterministic. If two implementations in two languages sign the same logical request, they must emit identical canonical bytes.

3. Hash the canonical request or build a string to sign

Many schemes do not HMAC the canonical request directly. They first hash it and then build a higher-level string to sign containing metadata such as algorithm and timestamp.

For the example above:

HMAC-SHA256
20260511T101530Z
581ac9bad7938320e33b6857f8124976fe327c6f5dcb0c1e998f19651d512eb7

That final hex value is the SHA-256 hash of the canonical request. Splitting the design into canonical request plus string-to-sign makes some schemes easier to version and extend.

4. Compute the HMAC tag with the shared secret

Using the shared key sk_live_4d7f6ef7c0f9479f8c8b9a15, the example string-to-sign produces:

2009fe0a4bf1d312942daf360445a9aad4368ecdf5deed09eb4c74f5a7916d9f

That is the request signature.

5. Send the signature plus the metadata the server needs

The server needs enough information to reconstruct the same string and choose the right key. That usually means:

  • credential or key id
  • timestamp
  • nonce if the scheme uses one
  • signed-header list
  • body hash if it is not derivable otherwise
  • signature

You can carry this in an Authorization header, in dedicated X-... headers, or both. The layout is not the security property. The determinism is.

A minimal Node example looks like this:

import { createHash, createHmac } from 'node:crypto'
 
function sha256Hex(input: string | Buffer): string {
  return createHash('sha256').update(input).digest('hex')
}
 
function canonicalQuery(params: URLSearchParams): string {
  return [...params.entries()]
    .sort(([ak, av], [bk, bv]) => (ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk)))
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join('&')
}
 
function canonicalHeaders(headers: Record<string, string>): { block: string; signed: string } {
  const entries = Object.entries(headers)
    .map(([k, v]) => [k.toLowerCase(), v.trim()] as const)
    .sort(([a], [b]) => a.localeCompare(b))
 
  return {
    block: entries.map(([k, v]) => `${k}:${v}\n`).join(''),
    signed: entries.map(([k]) => k).join(';'),
  }
}
 
function signRequest(secret: string, req: {
  method: string
  path: string
  query: URLSearchParams
  headers: Record<string, string>
  body: string
}) {
  const bodyHash = sha256Hex(req.body)
  const { block, signed } = canonicalHeaders({
    ...req.headers,
    'x-content-sha256': bodyHash,
  })
 
  const canonical = [
    req.method.toUpperCase(),
    req.path,
    canonicalQuery(req.query),
    block,
    signed,
    bodyHash,
  ].join('\n')
 
  const stringToSign = [
    'HMAC-SHA256',
    req.headers['x-date'],
    sha256Hex(canonical),
  ].join('\n')
 
  return createHmac('sha256', secret).update(stringToSign).digest('hex')
}

Nothing in that code is conceptually difficult. The danger is that every helper function encodes a protocol rule. Change one trimming rule, one sort order, or one percent-encoding behaviour, and interop breaks immediately.

The Server Must Reconstruct the Same Bytes and Verify in the Right Order

Verification is the mirror image of signing, but it usually has more failure paths because it deals with attacker input.

A careful verifier does roughly this:

  1. parse the presented auth metadata
  2. reject malformed timestamps, missing signed headers, and malformed hex or base64 before doing expensive work
  3. look up the shared secret by key id or credential id
  4. rebuild the canonical request from the received request bytes and headers
  5. recompute the string to sign
  6. compute the candidate HMAC tag
  7. compare candidate and presented tags in constant time
  8. enforce freshness window and replay rules
  9. only then hand the request to business logic

That ordering matters.

Parse first, but do not trust anything yet

The Authorization header might say:

HMAC-SHA256 Credential=client-berlin-7, SignedHeaders=content-type;host;x-content-sha256;x-date;x-nonce, Signature=2009fe0a...

The server can parse those fields syntactically. It must not assume they are sane. SignedHeaders may reference a header that does not exist. Signature may be the wrong length. Credential may not map to a real key.

Recompute from the request that actually arrived

This is where many implementations quietly go wrong. If the framework exposes a decoded path while the client signed the raw path, you have a representation mismatch. If the body parser already consumed and transformed the JSON before the verifier runs, you lost the bytes you needed. Verification middleware must sit early enough in the stack to see the real request representation the protocol expects.

Compare tags in constant time

In Node:

import { timingSafeEqual } from 'node:crypto'
 
function equalHexMac(expectedHex: string, presentedHex: string): boolean {
  const a = Buffer.from(expectedHex, 'hex')
  const b = Buffer.from(presentedHex, 'hex')
  if (a.length !== b.length) return false
  return timingSafeEqual(a, b)
}

Do not use expectedHex === presentedHex for MAC verification. The timing difference may be small, but request-signing code is exactly the kind of code that attracts repeated attacker probes.

Separate authentication failure from replay failure in logs

The client does not need a detailed public error. Your operators do. A useful internal log line looks more like:

verify_request failed: credential=client-berlin-7 reason=body_hash_mismatch path=/v1/transfers request_id=7f2c...

or:

verify_request failed: credential=client-berlin-7 reason=timestamp_out_of_window skew_seconds=612

That distinction matters during incident response. A body-hash mismatch suggests canonicalisation trouble or tampering. A timestamp skew spike usually means one side's clock drifted or a queue delayed delivery.

Beware of partial verification

A surprising number of broken systems verify the HMAC and stop there. But a valid HMAC over an old request is still an old request. A valid HMAC for the wrong host is still the wrong host if host was not part of the coverage. Authentication and freshness are separate checks.

Replay Protection Is the Part That Turns a MAC into a Request-Signing Protocol

Without replay protection, a signed request is only proof that someone who knew the key signed the bytes once. It is not proof that they meant this exact attempt, at this exact time, only once.

If an attacker captures:

POST /v1/transfers?from=wallet-eur&to=vendor-berlin
...
{"amount":"125.00","currency":"EUR","reference":"invoice-1842"}

plus its valid signature, they may be able to send it again later. The HMAC still verifies because the bytes did not change. If your API moves money, that is a catastrophe.

This is why serious schemes add at least a timestamp, and often a nonce.

Timestamps give you a freshness window

The client includes a time such as 20260511T101530Z. The server checks whether it falls within an allowed skew window, often ±300 seconds.

If the request arrives an hour late, reject it even if the HMAC is correct.

This is simple and effective, but not sufficient on its own. An attacker who captures the request can replay it repeatedly within that five-minute window.

Nonces let the server remember one-time use

A nonce is a client-generated unique token such as 8f1c2d44b7 or a UUID. The server keeps a short-lived replay cache keyed by something like:

credential_id + timestamp_window + nonce

On first use, it stores the nonce with a TTL. On second use within the validity window, it rejects the request.

With Redis the pattern is often:

SET replay:client-berlin-7:8f1c2d44b7 1 EX 300 NX

If NX fails, the nonce was already seen.

The storage cost is real

Replay caches are not free. At high request rates, storing every nonce for five minutes can become memory pressure. You have to budget cardinality, TTL, eviction policy, and what happens if the cache is unavailable.

This is where protocol design meets systems engineering. A public webhook endpoint receiving 50,000 events per second needs a replay store sized for that rate, or a design that makes retries idempotent downstream so that a missed replay rejection is still tolerable.

Idempotency keys are related but not identical

Payment APIs often also use an application-level idempotency key. That solves a different problem: the client may legitimately retry because it never got the response. The server should process the transfer once and return the same result for later retries.

Replay protection asks: "is this cryptographically fresh and first-seen?"

Idempotency asks: "if this logical business operation is repeated, should I deduplicate it?"

Good financial APIs usually want both.

Clock handling needs humility

Distributed systems lie about time all the time. NTP drift, VM resume events, container images with stale clocks, and delayed queues all happen. Do not choose a one-second freshness window because it looks secure on paper. Pick a window that tolerates your real infrastructure, then use nonces and idempotency to handle the residual replay risk inside that window.

Most Broken Schemes Fail in Canonicalisation, Not in HMAC

If you read incident reports and postmortems around request signing, the same categories appear repeatedly.

Path normalisation mismatches

Did the client sign /v1/files/%2e%2e/report or /v1/files/../report? Did one side collapse duplicate slashes? Did one side decode %2F into / before signing while the other preserved the raw path? Path handling rules must be explicit.

Query sorting and duplicate keys

Consider:

?tag=security&tag=api&tag=auth

If duplicate keys are permitted, the canonicalisation rule must say how to sort them and whether order is semantically meaningful. If the server turns them into an unordered map, information is lost.

Signing parsed JSON instead of raw bytes

This is probably the most common developer mistake.

Client sends:

{"amount":"125.00","currency":"EUR","reference":"invoice-1842"}

Server parses into an object and later reserialises as:

{"currency":"EUR","amount":"125.00","reference":"invoice-1842"}

Same object. Different bytes. Signature fails.

If the body is protected, sign the raw octets as transmitted, or sign a body hash computed from those octets before any parser touches them.

Proxy-rewritten host or path

Imagine the public URL is https://api.example.eu/v1/transfers, but the app behind the ingress sees:

Host: transfers.internal.svc.cluster.local
Path: /prod/v1/transfers

If the client signed the public form and the app verifies the internal form, every request breaks. You need a precise rule about whether verification uses the externally visible host and path, trusted forwarded headers, or the internal rewritten form. That rule must match what the signer used.

Header coverage gaps

If content-type is omitted from the signed set, a proxy or attacker may change how the server interprets the body without invalidating the signature. If host is omitted, cross-host replay may become possible. If x-date is unsigned, the attacker may alter the freshness metadata itself.

Unicode and normalisation edge cases

Paths, query parameters, and JSON strings may contain Unicode that has multiple equivalent normal forms. If one side normalises to NFC and the other preserves original bytes, signatures diverge. For most API designs the safe rule is simple: sign the exact UTF-8 bytes that appear in the canonical serialisation rules, and do not perform extra Unicode normalisation unless the protocol standard says to.

Transfer encoding confusion

Chunked transfer encoding, gzip compression, and proxy decompression can all confuse body protection if the signer and verifier disagree about whether they are signing the body before or after transport transformations. Usually you want to sign the application payload bytes, not the HTTP/1.1 chunk framing. But the rule must be precise.

The pattern behind all of these bugs is the same: someone thought they were signing "the request" as an abstract idea. In reality they were signing one exact representation while verifying another.

Key Management Is Where Small HMAC Schemes Start Looking Like Real Security Systems

At first glance, HMAC request signing seems operationally easy because it uses a shared secret rather than a public-key pair. There is no certificate chain, no JWKS endpoint, no RSA or ECDSA maths. But symmetric keys have their own tradeoffs.

Every verifier can also forge

This is the big architectural fact of symmetric MACs. If a server can verify an HMAC, it knows the same secret that would let it generate one. That means HMAC request signing is fine for direct client-to-server trust, but awkward for multi-party verification or delegated ecosystems.

A payment platform can give one merchant one secret and verify that merchant's requests. But if three internal services all share the same secret and all can verify requests, all three can also mint requests. Non-repudiation does not exist here.

Scope keys narrowly

Do not use one global HMAC secret for every integration. Issue per-client secrets, and ideally per-environment secrets as well. A staging key should not work in production. A merchant's write key should not authenticate administrative API calls it never needs.

The Credential= or key-id field in the auth metadata exists so the server can look up the correct secret without trying many candidates.

Rotate keys routinely and support overlap

Secrets leak through logs, screenshots, developer laptops, CI variables, and copy-paste mishaps. Rotation is not a theoretical exercise.

A sensible design supports at least:

  • one active key id per client
  • one next key id during planned rotation
  • short overlap where both old and new keys verify
  • fast emergency disable of a compromised key id

That means the request must carry the key id. Never make the server infer the key by trying every stored secret until one matches. That is inefficient, awkward to log, and easy to get wrong.

Derived signing keys reduce blast radius

AWS Signature Version 4 is instructive here. It does not use the raw long-term secret directly for each request. It derives scoped subkeys through a chain such as date, region, and service, then signs the request with the derived key.

Conceptually:

kDate    = HMAC("PREFIX" || secret, yyyymmdd)
kRegion  = HMAC(kDate, region)
kService = HMAC(kRegion, service)
kSign    = HMAC(kService, "request")
sig      = HMAC(kSign, string_to_sign)

That design limits reuse across scopes and makes offline compromise of one derived context less broadly useful. Not every internal API needs full SigV4-style derivation, but the principle is good: long-term secrets should not be sprayed directly across every signing operation if scoped derivation is cheap.

Store secrets like credentials, not config trivia

A shared HMAC secret is a credential with signing power. Treat it like one. Put it in a secret manager or HSM-backed store where appropriate. Do not hardcode it in mobile apps. Do not check it into Terraform examples. Do not leave it in build logs.

This is one reason HMAC signing is a poor fit for public browser clients. If the browser needs the secret to sign the request, the user and every script on the page effectively have the secret too. That destroys the security model.

HMAC Request Signing Is Excellent for Some Problems and Wrong for Others

Because the primitive is simple, teams sometimes try to use it everywhere. That is a mistake. HMAC signing is strong in some shapes and weak in others.

It works well for server-to-server APIs

If two backends under different administrative control need to authenticate direct calls, HMAC signing is a very reasonable choice. The client already has a secure place to store a secret. The server can look up the corresponding verification key. Replay windows and nonces are manageable.

It works well for webhooks

Webhook verification is usually the simplest form of this pattern. The sender signs the raw body plus a timestamp, and the receiver verifies the HMAC using a per-endpoint secret. Stripe and GitHub style webhook schemes are basically specialised request-signing protocols with narrower coverage.

It is awkward for browsers

A first-party web app running in the browser should almost never keep a long-term HMAC signing secret in JavaScript. If the page can sign arbitrary requests, then XSS can sign arbitrary requests too. Use server-set cookies, session state, or short-lived tokens delivered by a backend. Keep signing secrets on servers.

It is weak for third-party verification and delegation

If multiple independent parties need to verify that one actor signed a message without gaining the power to forge future messages, you want digital signatures, not HMAC. With HMAC, verifier and signer share the same secret. With Ed25519 or ECDSA, the signer keeps the private key and verifiers only know the public key.

It does not replace authorisation

A valid signature tells you which credential signed the request. It does not tell you whether that credential should be allowed to create transfers above 10,000 euros, rotate another client's key, or read audit logs. You still need authorisation policy after authentication.

It does not provide confidentiality

This is worth repeating because teams still get this wrong in design docs. HMAC makes tampering visible. It does not hide request data. Anyone who can see the request can still read it unless TLS or some other encryption layer protects it.

So the right comparison is not "HMAC or HTTPS". It is more like:

  • HMAC for symmetric origin authentication and integrity over a canonical request
  • mTLS when both sides can manage certificates and you want mutual transport authentication
  • JWT bearer tokens when you want portable delegated claims, usually for API access tokens
  • public-key signatures when many parties need to verify without being able to sign

Good systems pick the primitive that matches the trust topology, not the one that looks shortest in sample code.

The Operational Checklist Is More Important Than the One-Line HMAC Call

By the time a request-signing scheme is deployed, the interesting questions are operational.

Can both sides capture the exact raw body bytes?

If not, do not sign the body directly. Capture it earlier in the pipeline or sign a body hash computed before parsing.

Are canonicalisation rules fully written down?

"Sort the query" is not enough. What encoding? What about duplicates? What about empty values? What about spaces, plus signs, repeated slashes, and header folding? Protocols fail in the ambiguous corners.

Are timestamps and nonces both enforced?

Freshness without replay storage is incomplete. Replay storage without a time window grows without bound. You usually want both.

Is signature comparison constant time?

One helper call is enough. Still, teams forget it.

Is key rotation built in from day one?

If adding a second key id later requires redesigning the auth header, you waited too long.

Are failures observable?

You need metrics and logs broken down by category: malformed auth header, missing signed header, body hash mismatch, signature mismatch, clock skew, replay, unknown key id. Otherwise every customer integration issue becomes guesswork.

Is the scheme tested across real proxies and frameworks?

Unit tests that sign and verify inside one function in one language prove almost nothing. The dangerous bugs show up when a Java service signs, an Nginx ingress forwards, and a Node verifier reconstructs. Integration tests need those seams.

A good test matrix includes at least:

  • path percent-encoding edge cases
  • duplicate query parameters
  • empty body and empty query
  • header case changes
  • reordered headers
  • JSON bodies with different whitespace
  • delayed delivery past freshness window
  • nonce replay within freshness window
  • proxy-rewritten host and path scenarios

The lab that accompanies this post focuses on exactly these seams: canonical request assembly, HMAC verification, timestamp windows, and nonce replay.

A Signed Request Is Really a Small Protocol

The mistake people make is to think of request signing as a helper function.

It is not a helper function.

It is a protocol with:

  • a canonical request format
  • a string-to-sign format
  • an algorithm identifier
  • a key identification scheme
  • replay rules
  • clock rules
  • transport rules around raw bytes
  • logging and rotation rules

The HMAC call is only one line inside that protocol.

When you treat the whole design with that level of seriousness, HMAC request signing becomes pleasantly boring. A client assembles deterministic bytes, a server reconstructs the same bytes, both sides agree on clocks and nonce handling, and tampering turns into a clean mismatch. When you treat it casually, you end up with a system that is secure in a whiteboard sketch and brittle in production.

That is the real lesson. HMAC itself already solved the cryptographic part decades ago. RFC 2104 gave us the primitive. The engineering challenge is everything wrapped around it: choosing the right fields, canonicalising them exactly once, surviving infrastructure in the middle, and refusing to act on stale or replayed messages.

If you remember one thing, make it this: signed requests do not authenticate an abstract intention. They authenticate one exact sequence of bytes plus one freshness policy. Define those bytes precisely, enforce the freshness policy ruthlessly, and the scheme works. Get either part vague, and no amount of cryptographic confidence will save the implementation.