Preventing Replay Attacks on Spatial Webhooks

Stop a replayed spatial webhook from re-triggering a geometry mutation by signing a timestamp into the payload and rejecting anything outside a short freshness window, then claiming a per-message nonce in Redis with SET NX so a valid, correctly-signed message is accepted exactly once. This how-to sits under Webhook Security Boundaries, part of Core Event Fundamentals & Architecture.

A replay attack does not need to forge anything. The attacker captures one genuinely valid delivery — correct HMAC, correct coordinates, everything your signature check accepts — and simply sends it again. If that delivery said “move parcel boundary 42 to this new polygon” or “delete the geofence for zone A”, replaying it re-applies the mutation. Signature verification, covered in Signing Spatial Webhook Payloads with HMAC-SHA256, proves who sent the message and that it was not tampered with. It says nothing about when or how many times.

When to use this pattern

  • Your webhook triggers a state-changing spatial operation — writing a new geometry, mutating a feature’s bounds, toggling a geofence, or dispatching a physical asset — where re-applying the same event has real consequences.
  • You accept webhooks over the public internet from a provider or edge fleet where a network path could capture and resend a valid delivery, or where a buggy client retries aggressively.
  • You already sign payloads and now need the second half of the defence: freshness plus single-use, not just authenticity.

This is not the right tool when the operation is naturally read-only or already commutative, or when your only concern is a provider’s own well-behaved retries. Benign duplicate suppression is idempotency deduplication, a different problem with a different TTL — see Idempotency & Spatial Deduplication. The rest of this page is deliberate about keeping the two apart.

Why a freshness window alone is not enough

The instinct is to sign a timestamp and reject stale messages. That is necessary but insufficient. A freshness window bounds the duration an attacker can replay — but within that window, nothing stops them resending the same valid message a thousand times. If your window is five minutes, that is five minutes of unlimited re-triggering of a geometry mutation.

So you need two orthogonal defences working together. The signed timestamp bounds how long a captured message is dangerous. The per-message nonce bounds how many times any single message is accepted — exactly once. Neither substitutes for the other.

Replay defence: freshness gate then nonce gate An incoming signed webhook passes through a freshness window check on the signed timestamp, then a Redis SET NX nonce claim. A first delivery passes both gates and applies the geometry mutation. A replayed delivery passes the freshness gate but is rejected at the nonce gate because the key already exists. Signed webhook ts + delivery-id 1. Freshness |now − ts| ≤ 5 min 2. Nonce claim Redis SET NX (TTL) Redis seen delivery-ids Apply geometry mutation (once) stale → 401 key exists → replay, 409 Rejected — no mutation applied

Complete runnable implementation

The module below is self-contained. It verifies an HMAC-SHA256 signature over the exact bytes that include the timestamp and delivery id, enforces a five-minute freshness window, and then claims the nonce with a single atomic SET NX. Only if both gates pass does it apply the geometry mutation. Install redis (the modern redis-py, which bundles async support); the payload is plain JSON following RFC 7946 GeoJSON structure.

python
import hashlib
import hmac
import json
import time
from typing import Any

import redis  # redis-py >= 4.2 (bundles redis.asyncio)

# --- Configuration -----------------------------------------------------------
WEBHOOK_SECRET: bytes = b"change-me-to-a-32-byte-env-secret"
# Freshness window for the SECURITY replay check: short and adversarial.
FRESHNESS_WINDOW_SECONDS: int = 300          # 5 minutes
# Allowance for NTP drift between sender and receiver clocks.
CLOCK_SKEW_SECONDS: int = 30
# Nonce TTL must cover the whole window a replay could still look "fresh":
# freshness window + skew. Shorter than this leaves a replay gap.
NONCE_TTL_SECONDS: int = FRESHNESS_WINDOW_SECONDS + CLOCK_SKEW_SECONDS
NONCE_PREFIX = "replay:nonce:"

_redis = redis.Redis(host="localhost", port=6379, decode_responses=True)


class ReplayRejected(Exception):
    """Raised when a message is stale, unsigned/invalid, or already seen."""


def _expected_signature(signed_bytes: bytes) -> str:
    return hmac.new(WEBHOOK_SECRET, signed_bytes, hashlib.sha256).hexdigest()


def verify_fresh_and_signed(body: dict[str, Any], signature_hex: str) -> str:
    """
    Verify the HMAC over the signed bytes, then enforce the freshness window.
    The timestamp and delivery id are INSIDE the signed bytes, so an attacker
    cannot slide the timestamp forward without breaking the signature.
    Returns the delivery id (the nonce) on success; raises otherwise.
    """
    ts = int(body["ts"])
    delivery_id = str(body["delivery_id"])

    # Sign over a canonical, sorted serialization so sender and receiver agree
    # on the exact bytes. The ts and delivery_id are part of this payload.
    signed_bytes = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
    expected = _expected_signature(signed_bytes)
    if not hmac.compare_digest(signature_hex, expected):
        raise ReplayRejected("bad signature")  # not authentic — reject

    # Freshness gate: reject anything outside the short adversarial window.
    # Allow a little skew on BOTH sides (early clock as well as late).
    age = time.time() - ts
    if age > FRESHNESS_WINDOW_SECONDS + CLOCK_SKEW_SECONDS:
        raise ReplayRejected("stale timestamp")
    if age < -CLOCK_SKEW_SECONDS:
        raise ReplayRejected("timestamp in the future")

    return delivery_id


def claim_nonce(delivery_id: str) -> None:
    """
    Claim the delivery id exactly once. SET NX is atomic: the first caller
    creates the key and gets True; any replay finds it present and gets None.
    We FAIL CLOSED — if Redis is unreachable, we reject rather than risk an
    unbounded replay during the outage (this guards a geometry mutation).
    """
    key = NONCE_PREFIX + delivery_id
    try:
        created = _redis.set(key, "1", nx=True, ex=NONCE_TTL_SECONDS)
    except redis.RedisError as exc:
        raise ReplayRejected(f"nonce store unavailable: {exc}") from exc
    if not created:
        # Key already existed within its TTL -> this is a replay.
        raise ReplayRejected("replayed delivery_id")


def apply_geometry_mutation(body: dict[str, Any]) -> dict[str, Any]:
    """The protected side effect: mutate spatial state. Runs at most once
    per delivery_id because both gates ran first."""
    feature_id = body["feature_id"]
    geometry = body["geometry"]  # GeoJSON geometry, WGS 84 (EPSG:4326)
    # ... persist the new geometry for feature_id here ...
    return {"feature_id": feature_id, "type": geometry["type"], "status": "applied"}


def handle_webhook(body: dict[str, Any], signature_hex: str) -> dict[str, Any]:
    """Full ingress path. Order matters: authenticity, then freshness,
    then single-use nonce, then — and only then — the mutation."""
    delivery_id = verify_fresh_and_signed(body, signature_hex)  # gates 1 + 2a
    claim_nonce(delivery_id)                                     # gate 2b
    return apply_geometry_mutation(body)


def sign_payload(body: dict[str, Any]) -> str:
    """Helper a legitimate sender uses to produce the signature header."""
    signed_bytes = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
    return _expected_signature(signed_bytes)

The key discipline: the timestamp and delivery_id live inside the signed bytes. If they were unsigned header fields, an attacker could rewrite the timestamp to look fresh and swap the delivery id to dodge the nonce check while keeping a stolen signature. Signing them binds all three together.

Replay prevention versus idempotency dedup

These two use nearly identical Redis mechanics — a key, a TTL, a “have I seen this before” check — which is exactly why they get conflated. They are not the same job, and merging them weakens both.

Aspect Replay prevention (this page) Idempotency deduplication
Threat model Adversarial — attacker resends a valid capture Benign — provider retries after a timeout
Goal Reject the duplicate Absorb it, return the original result
Window / TTL Short (minutes), tied to freshness window Long (hours to days), tied to retry policy
On duplicate Reject: 409 / 401, no side effect Accept: replay the stored response, no re-mutation
Failure mode Fail closed (reject if store is down) Often fail open (rare duplicate is tolerable)
Key source Signed per-message nonce / delivery id Deterministic content or event key

If you try to serve both with one long-lived key, you either widen the replay window to hours (a security regression) or reject the provider’s legitimate retries (a reliability regression). Run them as two layers: the short adversarial nonce here, and a separate long-window dedup key downstream. The dedup side is covered in Idempotency & Spatial Deduplication, whose deterministic keys and long TTLs are designed for benign retries rather than attacks.

Parameter reference

Parameter Type Constraint / intent Default
WEBHOOK_SECRET bytes Min 32 bytes; load from env var, never hard-code — (must be set)
FRESHNESS_WINDOW_SECONDS int Short adversarial window; keep ≤ 900 s 300
CLOCK_SKEW_SECONDS int NTP drift allowance, applied to both sides 30
NONCE_TTL_SECONDS int Must be ≥ window + skew, or replays slip the gap 330
NONCE_PREFIX str Namespaces nonce keys away from dedup/idempotency keys "replay:nonce:"
body["ts"] int Unix epoch seconds; signed, not a header from payload
body["delivery_id"] str Globally unique per delivery (UUID/ULID); the nonce from payload
body["geometry"] dict GeoJSON geometry, WGS 84 (EPSG:4326), per RFC 7946 from payload
signature_hex str 64-char lowercase hex, HMAC-SHA256 output from header

Gotchas and spatial edge cases

  1. Clock skew cuts both ways. If the sender’s clock runs fast, a genuinely fresh message can look like it is from the future; if slow, it looks stale on arrival. Allow CLOCK_SKEW_SECONDS on both sides of the comparison, as the code does, and keep receivers on NTP. Do not widen the window to paper over unsynchronized clocks — that just lengthens the replay window.

  2. Freshness without a nonce is a false sense of security. The single most common mistake is shipping only the timestamp check. Inside a five-minute window an attacker replays freely. The nonce is not optional; it is the half that enforces exactly once.

  3. Nonce TTL shorter than the freshness window opens a gap. If the nonce expires at 60 s but a timestamp stays “fresh” for 300 s, a replay sent at 90 s finds no nonce and passes both gates. Always set NONCE_TTL_SECONDS >= FRESHNESS_WINDOW_SECONDS + CLOCK_SKEW_SECONDS.

  4. Nonce store outage — fail open or fail closed? For a boundary guarding geometry mutations, fail closed: if Redis is unreachable you cannot prove the message is new, so reject it. A fail-open path turns a Redis outage into an open replay window. This is the opposite default from a benign idempotency cache, where a missed dedup is merely a rare duplicate.

  5. The delivery id must be signed, not a bare header. If the nonce lives in an unsigned header, an attacker replays the stolen signature while mutating the delivery id to a fresh value and sails past the nonce gate. Put delivery_id inside the signed bytes so it cannot move independently of the signature. Signature construction is detailed in Signing Spatial Webhook Payloads with HMAC-SHA256.

  6. Do not reuse the idempotency key as the replay nonce. A deterministic content-hash key is designed to be identical across legitimate retries — which is precisely what you want dedup to absorb and precisely what you must not silently absorb in a security context. Keep separate key namespaces and separate TTLs; conflating them is the subtle bug that quietly disables one defence.

Verification

Run with pytest after pip install redis fakeredis pytest. The test proves the core property: the same signed message is accepted the first time and rejected the second.

python
import time
import pytest
import fakeredis

import replay_guard as g  # the module above, saved as replay_guard.py


@pytest.fixture(autouse=True)
def fake_redis(monkeypatch):
    monkeypatch.setattr(g, "_redis", fakeredis.FakeStrictRedis(decode_responses=True))


def _signed_message(delivery_id="del-abc-123", ts=None):
    body = {
        "delivery_id": delivery_id,
        "ts": int(ts if ts is not None else time.time()),
        "feature_id": "parcel-42",
        "geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},  # EPSG:4326
    }
    return body, g.sign_payload(body)


def test_first_delivery_applies_mutation():
    body, sig = _signed_message()
    result = g.handle_webhook(body, sig)
    assert result["status"] == "applied"


def test_replay_of_same_message_is_rejected():
    body, sig = _signed_message()
    g.handle_webhook(body, sig)                      # first time: accepted
    with pytest.raises(g.ReplayRejected, match="replayed delivery_id"):
        g.handle_webhook(body, sig)                  # exact replay: rejected


def test_stale_timestamp_rejected():
    body, sig = _signed_message(ts=time.time() - 3600)  # an hour old
    with pytest.raises(g.ReplayRejected, match="stale timestamp"):
        g.handle_webhook(body, sig)


def test_tampered_timestamp_breaks_signature():
    body, sig = _signed_message()
    body["ts"] = int(time.time())  # slide ts after signing -> signature no longer matches
    body["ts"] += 1
    with pytest.raises(g.ReplayRejected, match="bad signature"):
        g.handle_webhook(body, sig)

FAQ

Isn't a freshness window alone enough to stop replays?

No. A signed timestamp bounds how long a captured message stays valid, but an attacker can replay it many times inside that window. You still need a per-message nonce, tracked in Redis with SET NX, so the same delivery id is accepted exactly once regardless of how fast it is resent.

How is replay prevention different from idempotency deduplication?

They share Redis mechanics but differ in intent and TTL. Replay prevention is adversarial: a short window (minutes) and reject on a duplicate. Idempotency dedup is benign: it absorbs a provider’s own retries over a long window (hours to days) by returning the original result. Do not collapse them into one key with one TTL — see Idempotency & Spatial Deduplication for the dedup side.

Should the nonce store fail open or fail closed when Redis is down?

For a security boundary that guards geometry mutations, fail closed: reject the webhook if you cannot verify the nonce, because a fail-open path lets an attacker replay freely during an outage. Reserve fail-open behavior for non-adversarial idempotency caches where a rare duplicate is acceptable.

What should the nonce TTL be relative to the freshness window?

Set the nonce TTL at least as long as the freshness window plus your maximum clock skew. If the nonce expires before the timestamp goes stale, a replay can slip through in the gap. Matching the window (for example 300 seconds plus skew) keeps the two defences aligned.