Signing Spatial Webhook Payloads with HMAC-SHA256

Sign the RAW request body — the exact bytes on the wire — with HMAC-SHA256, and verify that signature BEFORE you parse, normalize, or reproject a single coordinate; reserializing GeoJSON changes the bytes and silently breaks the signature. This how-to sits under Webhook Security Boundaries, part of Core Event Fundamentals & Architecture.

Where the sibling guide Securing Webhook Endpoints with Spatial Token Validation embeds a spatial claim (a geohash or bounding box) inside the token and checks geographic containment, this page is narrower and more fundamental: pure body-integrity authentication. The only question here is “did the party holding the shared secret produce these exact bytes, unmodified in transit?” No geometry is trusted until that answer is yes.

When to use this pattern

  • The webhook provider (a mapping platform, a fleet telematics vendor, a tile-render service) publishes a shared secret and sends a signature header such as X-Signature-256: sha256=<hex>. HMAC verification is the standard way to authenticate that channel.
  • You ingest GeoJSON or other spatial payloads over the public internet and need tamper-evidence: a single flipped coordinate, a swapped feature ID, or an injected geometry must invalidate the request.
  • You want authentication that adds sub-millisecond overhead and no network round trip, unlike mutual TLS or an introspection call to an auth server.

This is not the right tool when you need to attest where an event physically originated — that is a geographic trust boundary, and spatial token validation or geofencing handles it. HMAC proves integrity and shared-secret possession, nothing about location. It also does not stop replay on its own; pair it with Preventing Replay Attacks on Spatial Webhooks when duplicate delivery is a threat.

Why reserialization breaks the signature

HMAC-SHA256 is a keyed hash over an exact byte sequence. The sender computes it over the bytes it puts on the wire; you must recompute it over the identical bytes. The spatial trap is that GeoJSON is JSON, and it is tempting to let your framework decode the JSON into a model and then hash that. But the moment you json.loads() and json.dumps() a payload — or worse, round-trip it through a geometry library — the bytes change in at least four ways that all break the digest.

Verify over raw bytes, not reserialized GeoJSON The raw request body flows into an HMAC-SHA256 comparison that matches the sender's signature and passes. A second path first parses and reserializes the GeoJSON — reordering keys, rounding floats, changing whitespace — producing different bytes whose HMAC no longer matches, and fails. Raw request body (bytes) HMAC-SHA256(secret, raw bytes) matches PASS parse + reserialize key reorder · float round whitespace · CRS reproject → different bytes mismatch FAIL

First, key order. A sender may emit {"type":"Feature","geometry":{...},"properties":{...}}; a naive re-dump under a different insertion order or a sort_keys=True policy rearranges those keys. Second, float representation. json.loads turns 2.000000 into the Python float 2.0, and re-dumping writes 2.0 — the string changed. Coordinate arrays such as [-122.41942, 37.77493] are especially fragile because any library that rounds to a fixed precision rewrites every vertex. Third, whitespace and separators: the sender’s compact , / : separators become , / : under default json.dumps. Fourth, and uniquely spatial, CRS reprojection. If your pipeline reprojects incoming coordinates from EPSG:4326 (WGS84) to EPSG:3857 (Web Mercator) before you hash, every number is different and the digest cannot possibly match. RFC 7946 mandates EPSG:4326 for GeoJSON, but many internal payloads carry other CRSs — never let that transform run ahead of verification.

The rule that dissolves all four problems: hash the raw bytes, once, before anything else reads them.

Complete runnable implementation

The FastAPI app below authenticates an inbound spatial webhook. The signature check is a dependency that runs before the route body executes, reads await request.body() exactly once, and caches the bytes on request.state so the handler can parse the GeoJSON after verification. Install fastapi and run with uvicorn app:app.

python
import hashlib
import hmac
import json
import os
from typing import Optional

from fastapi import Depends, FastAPI, Header, HTTPException, Request, status

app = FastAPI()

# --- Configuration -----------------------------------------------------------
# 32-byte (256-bit) shared secret. In production load it from a secret manager;
# os.environ ensures it is never hardcoded in source control.
WEBHOOK_SECRET: bytes = os.environ.get(
    "WEBHOOK_SECRET", "change-me-to-a-32-byte-env-secret"
).encode("utf-8")

# The provider's signature header format. Many vendors prefix the hex digest
# with an algorithm tag, e.g. "sha256=ab12...". We strip it before comparing.
SIGNATURE_PREFIX = "sha256="


def _expected_signature(raw_body: bytes) -> str:
    """HMAC-SHA256 over the EXACT request bytes — never the parsed payload."""
    return hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()


async def verify_signature(
    request: Request,
    x_signature_256: Optional[str] = Header(default=None),
) -> bytes:
    """
    FastAPI dependency. Returns the verified raw body so the handler can reuse
    it without re-reading the (already consumed) request stream.

    Ordering is the whole point: we read raw bytes and check the HMAC here,
    BEFORE any GeoJSON parsing or CRS normalization can mutate them.
    """
    if not x_signature_256:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing X-Signature-256 header",
        )

    # Read the body exactly once. Starlette caches it internally, but we also
    # stash it on request.state so downstream code never touches the stream.
    raw_body: bytes = await request.body()
    request.state.raw_body = raw_body

    # Strip the "sha256=" tag if the sender includes one.
    received = x_signature_256
    if received.startswith(SIGNATURE_PREFIX):
        received = received[len(SIGNATURE_PREFIX):]

    expected = _expected_signature(raw_body)

    # Constant-time comparison. NEVER use `received == expected` here — string
    # equality short-circuits and leaks a timing side channel byte by byte.
    if not hmac.compare_digest(received, expected):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid signature",
        )

    return raw_body


@app.post("/webhook/geo")
async def handle_geo_webhook(raw_body: bytes = Depends(verify_signature)) -> dict:
    # We only reach this line once the HMAC has passed. It is now safe to parse,
    # normalize, and reproject the GeoJSON — the bytes are authenticated.
    feature = json.loads(raw_body)

    # Example post-verification handling. Coordinates are assumed to be
    # EPSG:4326 (WGS84) per RFC 7946; reproject to EPSG:3857 (Web Mercator)
    # here if a downstream tile pipeline needs it — AFTER verification, never
    # before it.
    geometry = feature.get("geometry", {})
    coords = geometry.get("coordinates")

    return {"status": "accepted", "geometry_type": geometry.get("type"),
            "vertex_sample": coords}

The load-bearing detail is the Depends(verify_signature) wiring: FastAPI resolves the dependency before invoking the handler, so the HMAC gate is structurally impossible to skip, and the handler receives already-verified bytes rather than re-reading a consumed stream.

Parameter reference

Parameter Type Spatial / security constraint Default
WEBHOOK_SECRET bytes Min 32 bytes; load from a secret manager, never hardcode — (must be set)
raw_body bytes The EXACT wire bytes; must be hashed before any GeoJSON parse or CRS transform await request.body()
x_signature_256 str Hex digest, optionally sha256=-prefixed; from the provider header from header
SIGNATURE_PREFIX str Vendor-specific algorithm tag to strip before compare "sha256="
digestmod (hashlib.sha256) callable Must match the algorithm the sender used; SHA-256 = 64 hex chars hashlib.sha256
hmac.compare_digest(a, b) bool Constant-time; both args same type (str/str or bytes/bytes)

Gotchas and spatial edge cases

  1. Signing normalized instead of raw bytes. The defining mistake. If you verify against json.dumps(json.loads(raw_body)), key reordering, 2.0000002.0 float reformatting, and separator whitespace all shift the bytes and every legitimate request fails. Hash request.body() verbatim.

  2. CRS reprojection before verification. Reprojecting coordinates from EPSG:4326 (WGS84) to EPSG:3857 (Web Mercator) — or any datum shift — rewrites every number. It must run strictly after the HMAC passes. Keep reprojection out of middleware that sits in front of the signature check.

  3. Timing attacks via ==. String equality short-circuits at the first mismatched byte, leaking how far an attacker’s guess matched. Always use hmac.compare_digest, which runs in constant time.

  4. Body already consumed. A request stream reads once. If middleware or an earlier dependency parsed the body first, request.body() yields empty bytes and the digest is computed over nothing. Read and cache the raw body inside the signature dependency before any model binding.

  5. Encoding mismatches. The secret and any string inputs must encode consistently — use UTF-8 and keep the body as bytes, never str. A secret stored as text and .encode()-d with a different codec than the sender used produces a silent, permanent mismatch. compare_digest also requires both arguments be the same type.

  6. Ring orientation and coordinate order are irrelevant to HMAC — and that’s the point. HMAC does not understand geometry; it authenticates bytes. Do not “fix” polygon winding order or swap [lon, lat] before verifying. Validate and correct geometry only in the post-verification stage, where changing the bytes no longer matters.

  7. Secret rotation. To rotate without dropping events, hold a list of active secrets and accept the request if compare_digest passes against any one of them. Remove the old secret once traffic logs show no sender still using it.

Verification

Run this with pytest after installing fastapi and httpx. It exercises a known-good signature, a tampered body, and a bad signature.

python
import hashlib
import hmac
import json

from fastapi.testclient import TestClient

# Import the app and secret from the implementation above.
from app import app, WEBHOOK_SECRET

client = TestClient(app)

# A compact GeoJSON Feature in EPSG:4326 (WGS84), per RFC 7946.
BODY = json.dumps(
    {"type": "Feature",
     "geometry": {"type": "Point", "coordinates": [-122.41942, 37.77493]},
     "properties": {"id": "sensor-42"}},
    separators=(",", ":"),
).encode("utf-8")


def _sign(raw: bytes) -> str:
    return "sha256=" + hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()


def test_valid_signature_accepted():
    resp = client.post(
        "/webhook/geo",
        content=BODY,
        headers={"X-Signature-256": _sign(BODY),
                 "Content-Type": "application/json"},
    )
    assert resp.status_code == 200
    assert resp.json()["status"] == "accepted"


def test_tampered_body_rejected():
    # Sign the original bytes, then send different bytes: HMAC must fail.
    tampered = BODY.replace(b"37.77493", b"37.80000")
    resp = client.post(
        "/webhook/geo",
        content=tampered,
        headers={"X-Signature-256": _sign(BODY),
                 "Content-Type": "application/json"},
    )
    assert resp.status_code == 401


def test_bad_signature_rejected():
    resp = client.post(
        "/webhook/geo",
        content=BODY,
        headers={"X-Signature-256": "sha256=" + "0" * 64,
                 "Content-Type": "application/json"},
    )
    assert resp.status_code == 401


def test_reserialized_body_would_fail():
    # Prove the core claim: re-dumping the parsed payload changes the bytes,
    # so a signature over the ORIGINAL body no longer matches.
    reserialized = json.dumps(json.loads(BODY)).encode("utf-8")  # spaces added
    assert reserialized != BODY
    resp = client.post(
        "/webhook/geo",
        content=reserialized,
        headers={"X-Signature-256": _sign(BODY),
                 "Content-Type": "application/json"},
    )
    assert resp.status_code == 401

FAQ

Why can't I sign the parsed GeoJSON instead of the raw bytes?

Parsing and reserializing GeoJSON changes the bytes: json.loads followed by json.dumps can reorder object keys, alter whitespace, round or reformat coordinate floats, and rewrite escape sequences. HMAC is computed byte-for-byte, so any of these differences produces a different digest and the signature fails. Always sign and verify the exact raw request body.

Why must I use hmac.compare_digest instead of ==?

The == operator on strings or bytes short-circuits at the first differing byte, so its runtime leaks how many leading bytes an attacker guessed correctly. Over many requests this timing side channel lets an attacker recover a valid signature byte by byte. hmac.compare_digest runs in constant time regardless of where the first difference is, closing that channel.

The request body is empty when I try to verify it — what happened?

A request body stream can only be read once. If a framework middleware, a dependency, or an earlier Pydantic model already consumed the stream, request.body() returns empty bytes. In FastAPI, read and cache the raw body in the signature dependency (or middleware) before any model binding runs, and pass those cached bytes downstream.

How do I rotate the signing secret without dropping events?

Accept more than one secret during the overlap window. Compute the HMAC against each active secret and accept the request if any comparison passes, using hmac.compare_digest for each. Once every sender has moved to the new secret and logs show no traffic on the old one, remove it.