API keys in headers are easy to steal. Request signing isn't.
If your API just checks for Authorization: Bearer <token>, you're one intercepted request away from a full account compromise. Tokens can be logged, leaked via browser history, or captured by a proxy. Request signing solves a different problem: it proves that the request body hasn't been tampered with and that it was generated by whoever holds the secret — even if an attacker observes the traffic in transit.
This post walks through HMAC-SHA256 request signing in Python, from the client-side signer to server-side verification with replay attack protection.
Why Bearer Tokens Aren't Enough
Bearer tokens authenticate the caller. Request signing authenticates the message itself.
Consider a payment API: an attacker intercepts a legitimate POST /transfer with {"amount": 100, "to": "account_A"}. With a bare bearer token, they can replay the exact request as-is — same amount, same destination — if there's no replay protection. Worse, if they can sit in the middle, they can modify the amount before forwarding, since the token doesn't cover the body.
With request signing:
- The server rejects any request where the body doesn't match the signature
- Replay attacks are blocked by a timestamp + nonce embedded in the signature
- In-transit tampering is immediately detected
This is how AWS Signature v4, Stripe webhooks, GitHub webhook payloads, and most serious B2B APIs work. You sign a canonical representation of the request — method, path, timestamp, nonce, body hash — and the server verifies it independently.
The Signing Algorithm
We use HMAC-SHA256. The canonical string covers: HTTP method, request path, Unix timestamp, a random nonce, and the SHA-256 hash of the raw body.
import hmac
import hashlib
import time
import secrets
import json
from typing import Optional
def sign_request(
method: str,
path: str,
body: bytes,
secret_key: str,
timestamp: Optional[int] = None,
nonce: Optional[str] = None,
) -> dict:
# Returns headers to attach to the outgoing request
ts = timestamp or int(time.time())
nonce_val = nonce or secrets.token_hex(16)
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join([
method.upper(),
path,
str(ts),
nonce_val,
body_hash,
])
signature = hmac.new(
secret_key.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"X-Timestamp": str(ts),
"X-Nonce": nonce_val,
"X-Signature": signature,
}
def build_signed_request(
method: str,
path: str,
payload: dict,
secret_key: str,
) -> tuple[bytes, dict]:
# Returns (body_bytes, headers) ready for an HTTP client
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
headers = sign_request(method, path, body, secret_key)
headers["Content-Type"] = "application/json"
return body, headers
Usage with httpx:
import httpx
SECRET = "your-api-secret"
body, headers = build_signed_request(
method="POST",
path="/v1/transfer",
payload={"amount": 100, "to": "account_A"},
secret_key=SECRET,
)
response = httpx.post(
"https://api.example.com/v1/transfer",
content=body,
headers=headers,
)
One important detail: serialize the body with separators=(",", ":") for deterministic JSON output. The server must verify against the raw request bytes — never re-serialize. If the client sends {"a":1} and the server re-serializes to {"a": 1} (with a space), the hashes won't match and every request will fail.
Server-Side Verification
The server reconstructs the canonical string and verifies the signature with a constant-time comparison. It enforces a clock skew window and stores nonces in Redis to block replays.
import hmac
import hashlib
import time
import redis
from fastapi import Request, HTTPException, Depends
MAX_SKEW_SECONDS = 300 # 5-minute replay window
SECRET_KEY = "your-api-secret"
r = redis.Redis(host="localhost", decode_responses=True)
def check_and_store_nonce(nonce: str, ttl: int = MAX_SKEW_SECONDS) -> bool:
# NX flag: only succeeds once per nonce — atomic, no race condition
return r.set(f"nonce:{nonce}", "1", nx=True, ex=ttl) is not None
def verify_signature(
method: str,
path: str,
body: bytes,
timestamp: int,
nonce: str,
signature: str,
) -> bool:
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join([
method.upper(),
path,
str(timestamp),
nonce,
body_hash,
])
expected = hmac.new(
SECRET_KEY.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature) # constant-time!
async def require_signed_request(request: Request):
# FastAPI dependency — raises 401 if signature is missing or invalid
try:
timestamp = int(request.headers["X-Timestamp"])
nonce = request.headers["X-Nonce"]
signature = request.headers["X-Signature"]
except (KeyError, ValueError):
raise HTTPException(401, "Missing or malformed signature headers")
if abs(int(time.time()) - timestamp) > MAX_SKEW_SECONDS:
raise HTTPException(401, "Request timestamp out of acceptable window")
if not check_and_store_nonce(nonce):
raise HTTPException(401, "Nonce already used — replay detected")
body = await request.body()
if not verify_signature(
request.method, request.url.path, body, timestamp, nonce, signature
):
raise HTTPException(401, "Signature mismatch")
Wire it in as a FastAPI dependency:
from fastapi import FastAPI
app = FastAPI()
@app.post("/v1/transfer", dependencies=[Depends(require_signed_request)])
async def transfer(request: Request):
payload = await request.json()
return {"status": "ok"}
Two Non-Negotiable Details
Use constant-time comparison. Never use == on HMAC digests. A timing side-channel leaks how many bytes matched: an attacker measures response latency and can recover the expected signature in O(n) attempts. hmac.compare_digest() runs in fixed time regardless of where the strings diverge.
Store nonces in Redis, not in-process. An in-memory Python set() breaks the moment you run more than one worker process. Redis with the NX flag is atomic: only one worker can claim a given nonce, even under concurrent load. Set the TTL to match your clock skew window — nonces older than MAX_SKEW_SECONDS are already rejected by the timestamp check anyway.
What Request Signing Doesn't Cover
Signing is defense-in-depth, not a complete solution:
- Compromised secret key: a leaked key lets an attacker forge valid signatures. Rotate keys on a schedule; store them in a secrets manager, not in committed config files or environment variables pushed to git.
- Server-side integrity: signing proves the message came from the legitimate client. It says nothing about whether the server processed it correctly. Sign responses too if you need end-to-end integrity.
- TLS is still mandatory: signing prevents tampering, not eavesdropping. The request body is still readable in plaintext over HTTP. Always deploy over HTTPS — signing is a layer on top of transport encryption, not a replacement.
For a full checklist covering request signing, key rotation, TLS configuration, rate limiting, and API hardening in a single structured document, the AYI NEDJIMI security hardening checklists are freely available as PDF and Excel.
The Takeaway
Request signing shifts the security model from "prove you know the key" to "prove this exact message, byte-for-byte unmodified, was authored by someone who knows the key." The implementation is under 80 lines of idiomatic Python, but the security improvement over bare bearer tokens is substantial for any API where body integrity matters: payment flows, admin endpoints, B2B integrations, or any webhook that triggers an irreversible action.
The recipe: HMAC-SHA256 over a canonical string that includes method, path, timestamp, nonce, and body hash. Store nonces atomically in Redis. Verify with hmac.compare_digest. Enforce a clock skew window. That's the complete implementation.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)