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.
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.
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
-
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_SECONDSon 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. -
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.
-
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. -
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.
-
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_idinside the signed bytes so it cannot move independently of the signature. Signature construction is detailed in Signing Spatial Webhook Payloads with HMAC-SHA256. -
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.
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.
Related
- Webhook Security Boundaries — parent overview of the trust model for geospatial event ingress, of which replay prevention is one gate
- Signing Spatial Webhook Payloads with HMAC-SHA256 — how to construct the signature whose bytes must include the timestamp and delivery id
- Idempotency & Spatial Deduplication — the benign, long-window counterpart to adversarial replay prevention, for absorbing a provider’s own retries