Skip to content

Build1 publisher3 min readPublished

Your bearer token signs nothing: HMAC body signing is the gap in money-moving APIs

A dev.to walkthrough names a specific hole: a token authenticates the caller, not the message. HMAC-SHA256 over a canonical string, with a timestamp and a nonce, closes it.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • Bearer tokens authenticate the caller; request signing authenticates the message itself, proving the body has not been tampered with and was generated by the holder of the secret.
  • In the post's payment API example, an attacker who intercepts a legitimate POST /transfer with body {"amount": 100, "to": "account_A"} can replay the exact request as-is if there is no replay protection.
  • An attacker positioned in the middle can modify the amount before forwarding the request, because the bearer token does not cover the body.
  • Tokens can be logged, leaked via browser history, or captured by a proxy.
  • With request signing, the server rejects any request where the body does not match the signature, replay attacks are blocked by a timestamp plus nonce embedded in the signature, and in-transit tampering is immediately detected.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A walkthrough published on dev.to states the one thing an `Authorization: Bearer` header does not do: it identifies the caller and says nothing about the bytes the caller sent [1]. For any endpoint that moves money or mutates state, that gap is the attack surface, and the remedy is narrow enough to specify in a paragraph rather than filed under general hardening.

The post's worked example is a payment endpoint. An attacker who captures a legitimate `POST /transfer` carrying `{"amount": 100, "to": "account_A"}` can replay it verbatim when the server has no replay protection [2], and an attacker sitting in the middle can change the amount before forwarding, because the token does not cover the body [3]. Tokens leak in unglamorous ways: application logs, browser history, an intercepting proxy [4]. Signing changes the failure mode: a body that does not match the signature is rejected, replay is blocked by the timestamp and nonce folded into the signature, and in-transit tampering is detected [5]. According to the author, this is the pattern behind AWS Signature v4, Stripe webhooks and GitHub webhook payloads [6].

The construction is small. The canonical string is method, path, Unix timestamp, random nonce and the SHA-256 hex digest of the raw body, joined with newlines [7]; the client HMACs that with the shared secret and ships `X-Timestamp`, `X-Nonce` and `X-Signature` [8]. The nonce defaults to `secrets.token_hex(16)` [9], which is 128 bits [1].

The interesting part is the operational detail, because that is where these schemes break in production. The client serializes JSON with `separators=(",", ":")` for determinism [10], and the server must hash the raw request bytes and never re-serialize: `{"a":1}` and `{"a": 1}` hash differently, and every request fails [11]. Comparison is `hmac.compare_digest` rather than `==` [12]. The server enforces a 300 second skew window [13], five minutes [2], and stores nonces in Redis with `SET ... NX` and an expiry so the check is atomic and cannot race [14]. A missing or invalid signature raises 401 [15].

Two limits are visible in the code as published. The canonical string covers the path but not the query string or the host, so two requests differing only in query parameters produce the same signature [3] - fine for JSON bodies, a real hole if any mutating handler reads filters from the URL. And the nonce TTL defaults to the skew window [16] against a Redis instance addressed at localhost [17]; lose that store to a flush or a failover inside 300 seconds and replay protection degrades to the timestamp window alone [4]. A shared, replicated nonce store is not optional once you run more than one API process.

The available excerpt ends part-way through the FastAPI dependency [18], so secret distribution and rotation are out of frame. Anyone lifting this needs an answer for where each client's secret lives, how a compromised one is retired without downtime, and whether their framework's `path` value silently includes or excludes the query string.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories