DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Implementing Request Signing for API Security: HMAC + Timestamps

Implementing Request Signing for API Security: HMAC + Timestamps

API keys in headers are not enough. If a request is intercepted, that key can be replayed indefinitely. Request signing forces every call to carry a cryptographic proof tied to both the payload and a timestamp — so a captured request is worthless 30 seconds later.

This post walks through a production-ready HMAC signing scheme in Python: how it works, the edge cases that trip people up, and a full client/server implementation you can drop into any HTTP service.

Why API Keys Alone Fall Short

A bearer token (Authorization: Bearer abc123) authenticates the caller but nothing else. An attacker who sniffs that token can:

  • Replay the exact same request at any time
  • Tamper with the body without invalidating the credential
  • Spoof any endpoint or method combination

HMAC signing addresses all three. The client computes a signature over (method + path + body_hash + timestamp), sends it alongside the request, and the server rejects anything where the signature doesn't match or the timestamp is stale.

This pattern is used in production by AWS (SigV4), Stripe (webhook verification), and GitHub (webhooks). It is not exotic — it is the baseline.

The Signing Scheme

The canonical construction:

string_to_sign = METHOD + "\n" + PATH + "\n" + SHA256(body) + "\n" + TIMESTAMP
signature      = HMAC-SHA256(secret_key, string_to_sign)
Enter fullscreen mode Exit fullscreen mode

Headers sent with every request:

  • X-Timestamp: Unix timestamp in seconds
  • X-Signature: hex-encoded HMAC

The server:

  1. Rejects requests where |now - X-Timestamp| > 30s
  2. Recomputes the signature from its own inputs
  3. Compares with hmac.compare_digest to prevent timing attacks

Python Client Implementation

import hashlib
import hmac
import json
import time
from urllib.parse import urlparse

import httpx


def sign_request(method: str, path: str, body: bytes, secret: str) -> dict[str, str]:
    timestamp = str(int(time.time()))
    body_hash = hashlib.sha256(body).hexdigest()

    string_to_sign = "\n".join([method.upper(), path, body_hash, timestamp])
    signature = hmac.new(
        secret.encode(),
        string_to_sign.encode(),
        hashlib.sha256,
    ).hexdigest()

    return {"X-Timestamp": timestamp, "X-Signature": signature}


def call_api(url: str, payload: dict, secret: str) -> httpx.Response:
    # Use separators to get deterministic JSON (no extra spaces)
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()

    path = urlparse(url).path or "/"
    headers = sign_request("POST", path, body, secret)
    headers["Content-Type"] = "application/json"

    return httpx.post(url, content=body, headers=headers)
Enter fullscreen mode Exit fullscreen mode

One critical detail: JSON serialization is not deterministic by default. json.dumps({"b": 1, "a": 2}) and json.dumps({"a": 2, "b": 1}) produce different byte sequences, which breaks signature verification. Always pass sort_keys=True and consistent separators on both sides, or hash the raw bytes as received rather than re-serializing.

Python Server (FastAPI)

import hashlib
import hmac
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = "your-secret-key"
MAX_CLOCK_SKEW = 30  # seconds


async def verify_signature(request: Request) -> None:
    timestamp = request.headers.get("X-Timestamp")
    signature = request.headers.get("X-Signature")

    if not timestamp or not signature:
        raise HTTPException(status_code=401, detail="Missing signature headers")

    try:
        ts = int(timestamp)
    except ValueError:
        raise HTTPException(status_code=401, detail="Invalid timestamp")

    if abs(time.time() - ts) > MAX_CLOCK_SKEW:
        raise HTTPException(status_code=401, detail="Request expired")

    body = await request.body()
    body_hash = hashlib.sha256(body).hexdigest()

    string_to_sign = "\n".join([
        request.method,
        request.url.path,
        body_hash,
        timestamp,
    ])

    expected = hmac.new(
        SECRET.encode(),
        string_to_sign.encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise HTTPException(status_code=401, detail="Invalid signature")


@app.post("/data")
async def ingest(request: Request):
    await verify_signature(request)
    payload = await request.json()
    return {"status": "ok", "received": payload}
Enter fullscreen mode Exit fullscreen mode

The hmac.compare_digest call is not optional. A naive == comparison leaks timing information: an attacker can submit thousands of wrong signatures and use response time variance to brute-force the key one byte at a time. This is a real attack class, not a theoretical one.

Edge Cases That Break Naive Implementations

Clock drift. Two servers whose clocks differ by more than your tolerance window will reject each other's valid requests. Use NTP on both sides and set your skew to at least 30 seconds. For cross-cloud-provider calls, 60 seconds is safer.

Double-read of the body. ASGI frameworks like FastAPI read the body stream once. If your middleware reads it for verification, the route handler receives an empty body. The pattern above re-reads from request.body(), which Starlette caches after the first read. If you use a middleware-based approach instead of a dependency, store the body in request.state and pass it through.

Nonce deduplication. A timestamp alone does not prevent replays within the tolerance window. If that matters — payment endpoints, destructive operations — add a random nonce to the signed string and cache seen nonces in Redis with a TTL equal to your skew window. This is exactly what AWS SigV4 does.

Canonical path. /api/v1/data and /api/v1/data/ are the same route in most frameworks but produce different signatures. Agree on a normalization rule (strip trailing slashes, lowercase) and apply it on both sides before signing.

Testing Your Scheme

A minimal test suite should cover three cases:

def test_valid_request_accepted(client):
    response = client.post_signed("/data", json={"x": 1})
    assert response.status_code == 200


def test_tampered_body_rejected(client):
    # Sign {"x": 1}, send {"x": 2}
    response = client.post_tampered("/data", sign_body={"x": 1}, send_body={"x": 2})
    assert response.status_code == 401


def test_expired_timestamp_rejected(client):
    # Backdate timestamp by 60 seconds
    response = client.post_signed("/data", json={"x": 1}, timestamp_offset=-60)
    assert response.status_code == 401
Enter fullscreen mode Exit fullscreen mode

If you roll this pattern across multiple internal services, extract the signing logic into a shared library. Each team reimplementing from scratch is how subtle divergences appear — a service that forgets to include the HTTP method in string_to_sign becomes vulnerable to cross-method replay attacks.

The Takeaway

HMAC request signing is a 50-line addition that eliminates replay attacks, body tampering, and stale credential reuse in one shot. The cryptography is proven; the implementation is straightforward. The only hard part is consistency: client and server must agree on exactly the same canonical string, same encoding, and same timestamp format.

If you are building API authentication policies across a team, our free security hardening checklists cover API authentication, secrets management, and transport-layer security in a format you can use as a review checklist or onboarding guide.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)