Preventing Replay Attacks: A Developer's Guide to Agentic Email Webhook Security
Learn how to stop a replayed inbound email webhook from making your agent send, book, or approve twice — with the exact signature check, timestamp window, and idempotency keys to implement.
Robust agentic email webhook payload replay protection requires three sequential gates: validating the cryptographic signature over raw request bytes, enforcing a strict timestamp acceptance window, and deduplicating deliveries via an atomic cache key. Implementing all three stops an intercepted payload or an upstream retry from tricking your AI agent into executing the same real-world side effect multiple times.
When an autonomous agent handles email, duplicate execution carries immediate consequences. An agent receiving an inbound email webhook might parse customer instructions, execute a database write, reserve a calendar slot, or send an unrecoverable outbound reply. Because HTTP webhooks rely on at-least-once delivery over public networks, network interruptions and edge proxy retries mean duplicate requests will hit your receiver. Without end-to-end webhook replay attack prevention, your application layer processes validly signed yet stale or repeated payloads, triggering costly downstream loops.
The replay attack that breaks an agentic email webhook
A webhook replay attack occurs when an adversary captures a valid, signed HTTP request sent to your receiver and retransmits it to your endpoint. The failure mode also emerges non-maliciously when edge routers, load balancers, or upstream webhook schedulers encounter transient network drops and retry a payload that your application already executed. In both scenarios, the payload body and cryptographic headers remain completely authentic. If your handler only verifies that the signature matches the secret, the replayed request evaluates as entirely valid, and your agent acts twice.
Cryptographic signatures prove authenticity and payload integrity: they establish that AgentDraft produced the exact bytes in transit using your shared secret. A signature does not prove freshness, nor does it assert that the message has not been seen. RFC 2104 explains how keyed-hashing mechanisms guarantee data integrity (RFC 2104: HMAC: Keyed-Hashing for Message Authentication). Without timestamp constraints and unique delivery tracking, an HMAC-SHA256 signature remains mathematically valid indefinite minutes, days, or months after emission.
In standard SaaS architectures, a duplicated webhook might just update a redundant database column. For AI agents, the blast radius of a duplicate webhook is destructive:
- Duplicate outbound email: An agent wired to an inbound mailbox generates a second LLM completion and sends an identical, redundant reply to a recipient, degrading trust or leaking operational churn.
- Race conditions on shared resources: An agent parsing scheduling instructions attempts a second booking commit on an already reserved slot, creating downstream concurrency exceptions.
- Duplicated human approvals: Workflows that pause for sign-off generate duplicate pending approval requests in the dashboard, confusing human reviewers.
- Polluted audit records: Every state transition writes to an audit log; phantom runs create spurious log entries that complicate forensic tracking.
Securing your agent requires a concrete, three-part defense: verify the signature using raw body bytes, enforce a strict timestamp window, and deduplicate on a stable delivery identifier. Guidelines outlined in NIST SP 800-52 Rev. 2 emphasize securing data in transit through the proper selection and configuration of Transport Layer Security (TLS) implementations. AgentDraft signs webhook deliveries using the X-AgentDraft-Signature header with the format t=<unix>,v1=<hex HMAC-SHA256> . Inspecting this header allows your handler to inspect both the creation time and the message integrity before running your agent code.
How AgentDraft signs a webhook delivery
AgentDraft signs every webhook delivery with an X-AgentDraft-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> header computed over <t>. followed by the raw request body, using the workspace's signing secret. The receiver verifies it; AgentDraft recommends that receivers reject any timestamp more than 300 seconds (5 minutes) from the current time to block replays, and its documented reference verifier does so. Signed webhook delivery is available on every plan, including the free Developer tier.
The signature structure binds the timestamp directly into the cryptographic digest:
X-AgentDraft-Signature: t=1776081600,v1=9b1fec3b5a74e2...d6c7
The construction string fed to the HMAC calculation is <t> + '.' + rawBody. This format prevents an attacker from decoupling an old signature from its timestamp or swapping out an expired timestamp while keeping the signature intact. Any modification to t changes the computed digest entirely.
The payload must be validated against the exact raw bytes received across the network. Modern web frameworks (such as Express, Fastify, Next.js, Flask, or Django) often parse inbound requests into internal dictionary or JSON object representations. If you attempt to verify the signature by re-serializing this object with JSON.stringify(req.body) , whitespace, escape characters, and key orders will shift. Those discrepancies invalidate the HMAC match, triggering false 401 Unauthorized rejections. Handlers can extract and preserve the raw binary or text buffer before downstream body parsers execute.
Webhook signing secrets are provisioned on a per-workspace basis. When rotating keys, update your handler to accept signatures from both the old and new secrets during a migration window to avoid dropping in-flight messages. Because signed webhook delivery is available on every plan, including the free Developer tier, you can configure and test your handlers against live signatures without commercial commitments. Read the AgentDraft webhook documentation for specific payload shapes and event definitions.
Step 1: verify X-AgentDraft-Signature in constant time
The initial gate inspects the X-AgentDraft-Signature header. The receiver splits the header on the comma delimiter to extract the timestamp string t and the hash v1. If either key is missing, or if the header formatting is broken, immediately abort execution with an HTTP 400 Bad Request.
Next, recalculate the HMAC-SHA256 digest using your workspace signing secret over the string representation of t, a literal dot (.), and the raw request payload buffer:
signed_data = t + "." + raw_body
expected_signature = hmac_sha256(secret, signed_data).hexdigest()
When comparing the derived hash against the provided v1 value, do not use standard equality operators such as == or ===. Standard equality short-circuits on the first non-matching byte, which leaks timing information to external callers. An attacker can exploit these subtle differences over many requests to infer valid signatures byte-by-byte. The OWASP Foundation details how timing discrepancies compromise cryptographic validations (OWASP Cryptographic Storage Cheat Sheet). Always use constant-time comparison methods, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.
If the constant-time check fails, return an HTTP 401 Unauthorized status code. Log the failure alongside the message identifier; avoid returning the expected digest in the response payload or error string. Keep error responses opaque to prevent exposing cryptographic context to unauthorized callers, consistent with guidance in RFC 9110 HTTP Semantics regarding safe client error responses.
Step 2: reject stale timestamps to block replay
Validating the cryptographic signature confirms that the payload arrived unmodified from AgentDraft. It does not establish that the payload is fresh. To enforce agentic email webhook payload replay protection, the receiver must evaluate the header timestamp against its own clock.
AgentDraft recommends that receivers reject any delivery whose t is more than 300 seconds (5 minutes) from the current time. This check must occur on your infrastructure because the sender cannot anticipate your network latency, queue worker backlog, or internal system time. Enforcing freshness on the receiver ensures that an attacker possessing an intercepted payload only has a narrow temporal window to attempt a replay.
The comparison requires an absolute difference calculation rather than a simple one-sided check:
current_time = unix_timestamp_now()
if abs(current_time - t) > 300:
return 401, "stale_timestamp"
Checking the absolute delta prevents attacks where an adversary modifies an unverified request header with a timestamp set arbitrarily far in the future to bypass a standard current_time - t > 300 check. If the difference in either direction exceeds 300 seconds, abort processing.
Selecting the duration of the timestamp window involves operational tradeoffs. The 300-second default accommodates typical internet routing delays and minor host clock skew. Tightening this window to 30 or 60 seconds reduces the replay exposure window, but increases the probability of false-positive rejections if your server clock drifts or upstream retries take longer to arrive over congested pipes. Network synchronization mechanisms like NTP, defined in RFC 5905 Network Time Protocol Version 4, maintain the tight clock alignment needed for these temporal assertions. When rejecting a request under this check, return an HTTP 401 Unauthorized (or 408 Request Timeout) with an internal error code such as stale_timestamp to distinguish timestamp expiration from bad secret signatures in your telemetry.
Step 3: dedupe on delivery ID so retries do not double-act
The 300-second timestamp window prevents an attacker from replaying an intercepted payload hours later. However, it does not prevent an identical request from being processed twice within that 5-minute span. Upstream retries caused by a transient HTTP timeout can easily deliver a duplicate message 20 seconds after the original was processed.
To eliminate duplicate processing, store the webhook delivery ID (or a SHA-256 hash of t + rawBody if a distinct delivery ID header is unavailable) in a high-speed, persistent key-value cache like Redis. Use atomic write primitives such as SET key value NX EX 86400. This command sets the key only if it does not already exist, assigning an expiration TTL (such as 24 hours) in a single round-trip.
If the cache write returns false, your system has already received and accepted that delivery. The handler should immediately exit and return an HTTP 200 OK status code. Do not return an HTTP 4xx or 5xx status code for duplicate deliveries. Returning an error status signals upstream retry mechanisms that your endpoint failed to process the payload, causing the sender to retransmit the webhook repeatedly and amplifying the traffic spike. Returning 200 OK acknowledges receipt and halts further retries.
Understand the boundary between webhook ingestion deduplication and downstream action idempotency. Even with webhook deduplication active, your agent may experience internal step failures while invoking downstream APIs. If your agent proceeds to commit resources—such as calling bookings:write—you should forward an explicit idempotency key to downstream systems. AgentDraft coordinates holds and commits through a priority-aware conflict engine so multiple agents can act on the same calendar without double-booking. When scheduling bookings, use the calendar API for agents to ensure downstream holds remain isolated across multiple worker nodes.
Handlers enforce execution in strict order:
- Verify the HMAC signature.
- Verify the timestamp freshness window.
- Atomically write the deduplication key.
- Enqueue the agent processing job or process the payload.
Only enqueue your agent worker after verifying all three gates.
A reference handler, end to end
The following Python reference handler demonstrates the verification sequence using a framework like FastAPI or Starlette. It enforces constant-time signature verification, checks the 300-second freshness window, deduplicates deliveries using Redis, and routes valid events:
import hmac
import hashlib
import time
import redis
from fastapi import FastAPI, Request, Response, status
app = FastAPI()
r = redis.Redis(host='localhost', port=6379, db=0)
SIGNING_SECRET = b"whsec_your_workspace_secret_here"
MAX_DRIFT_SECONDS = 300
@app.post("/webhooks/agentdraft")
async def handle_agentdraft_webhook(request: Request):
# Step 0: Capture the raw byte stream
raw_body = await request.body()
sig_header = request.headers.get("X-AgentDraft-Signature")
delivery_id = request.headers.get("X-AgentDraft-Delivery-Id")
# Gate 1: Check header structure
if not sig_header:
return Response(
content='{"error":"missing_signature"}',
status_code=status.HTTP_400_BAD_REQUEST,
media_type="application/json"
)
parts = dict(param.split("=", 1) for param in sig_header.split(","))
timestamp_str = parts.get("t")
v1_sig = parts.get("v1")
if not timestamp_str or not v1_sig:
return Response(
content='{"error":"malformed_signature_header"}',
status_code=status.HTTP_400_BAD_REQUEST,
media_type="application/json"
)
# Gate 2: Constant-time signature verification
signed_payload = f"{timestamp_str}.".encode("utf-8") + raw_body
computed_sig = hmac.new(SIGNING_SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(computed_sig, v1_sig):
return Response(
content='{"error":"invalid_signature"}',
status_code=status.HTTP_401_UNAUTHORIZED,
media_type="application/json"
)
# Gate 3: Reject stale timestamps
try:
t = int(timestamp_str)
except ValueError:
return Response(
content='{"error":"invalid_timestamp"}',
status_code=status.HTTP_400_BAD_REQUEST,
media_type="application/json"
)
now = int(time.time())
if abs(now - t) > MAX_DRIFT_SECONDS:
return Response(
content='{"error":"stale_delivery"}',
status_code=status.HTTP_401_UNAUTHORIZED,
media_type="application/json"
)
# Gate 4: Deduplicate against Redis
# Use delivery_id or fallback to a hash of the signature string
dedupe_key = f"webhook:dedupe:{delivery_id or v1_sig}"
is_new = r.set(dedupe_key, "processed", nx=True, ex=86400)
if not is_new:
# Acknowledge duplicate delivery without triggering secondary actions
return Response(
content='{"status":"duplicate_ignored"}',
status_code=status.HTTP_200_OK,
media_type="application/json"
)
# Gate 5: Hand off valid, fresh, unseen payload to worker
# e.g., enqueue_agent_task(raw_body)
return Response(
content='{"status":"accepted"}',
status_code=status.HTTP_202_ACCEPTED,
media_type="application/json"
)
Every gate addresses a specific threat vector. Skipping Gate 2 allows attackers to spoof payloads arbitrarily. Skipping Gate 3 exposes your system to delayed replay attacks using previously captured headers. Skipping Gate 4 causes your agent to double-process network retries. This protection architecture is not limited to inbound email; apply the same pattern to incoming approval.* webhooks that handle human sign-off lifecycles. AgentDraft records state-changing agent actions in an append-only audit trail, ensuring that all upstream webhooks and triggered events can be correlated against administrative logs.
Edge cases that still bite after you implement all three gates
Deploying these three verification gates stops standard replay attacks, but real-world distributed environments present several common edge cases.
Clock drift across server nodes
If your application hosts drift away from true UTC by even two or three minutes, legitimate inbound requests will fall outside the 300-second window. The handler will log stale_delivery errors while the sender reports delivery failures. Ensure your servers synchronize with Network Time Protocol (NTP) daemons like chrony or systemd-timesyncd. Instrument metrics tracking now - t so your monitoring stack alerts you if average timestamp offsets widen.
Handling secret rotation seamlessly
Changing your webhook secret immediately invalidates all signatures computed by upstream services still using the previous key. When updating secrets, configure your verification gate to test incoming signatures against the new primary secret first. If verification fails, test against the retired secondary secret before returning a 401 Unauthorized response. Once upstream traffic confirms the new secret is active across all calls, unbind the old key entirely.
At-least-once delivery guarantees
Webhooks rely on distributed message brokers, which inherently prioritize at-least-once delivery over exactly-once delivery. Network latency, socket disconnects, or delayed ACKs will routinely trigger duplicate transmissions. Do not treat a duplicate message as an intrusion attempt: treat deduplication as a standard operational requirement for system correctness.
Partial failures during agent task processing
Consider what happens if your handler accepts a webhook, sets the Redis dedupe key, returns a 202 Accepted, and then crashes mid-execution while the agent is running. If upstream retries the delivery, your dedupe gate will see the key and return 200 OK, dropping the message. To guard against this, separate your ingestion deduplication key from your execution state. Alternatively, persist the inbound message body to an internal queue before confirming the dedupe lock.
Multi-tenant key isolation
If you operate a multi-tenant platform receiving webhooks for different customer accounts, you must isolate deduplication keys and signing secrets by workspace identifier. If dedupe keys are evaluated in a shared global Redis namespace without workspace prefixes, a message identifier collision could inadvertently suppress a legitimate webhook intended for a different customer.
What replay protection does not cover
While strict payload validation and timestamp bounds prevent retransmission attacks, they do not resolve all agent security challenges. Replay protection is an edge boundary control, not a replacement for domain authorization or resource safety.
Payload replay protection will not prevent an agent with compromised credentials from executing unauthorized actions directly against internal APIs. To isolate blast radius, use scoped credentials. AgentDraft agents authenticate with bearer API keys prefixed avs_live_, stored argon2id-hashed. Each key carries explicit scopes (availability:read, bookings:read, bookings:write, mailbox:read, mailbox:write, rules:read, approvals:request); new keys default to availability:read and bookings:write, and approvals:request must be granted per key.
Replay defenses will also not stop an agent from deciding to perform a duplicate real-world action based on flawed internal reasoning, such as an LLM processing an email and attempting to schedule back-to-back calendar events. To control autonomous behavior, implement human-in-the-loop checkpoints. AgentDraft lets an agent pause any consequential action for human sign-off: it opens an approval request carrying a one-line summary and a JSON evidence payload, a person approves or denies it in the dashboard with an optional note, and the agent reads the outcome back. The gated action does not have to be one AgentDraft performs — a deploy, a migration, or a refund is gated the same way. Every transition lands in the append-only audit trail and fires an approval.* webhook.
Finally, replay protection does not replace an immutable audit log. Replay verification ensures an endpoint will not process repeated payloads, but when operational questions arise regarding what actions were taken and when, you need structural audit logs. Review the audit trail documentation to learn how agent execution states are preserved.
AgentDraft is a proprietary hosted API; it is not open source and is not offered as a self-hosted or on-premise product. Because webhook endpoints execute within your own external infrastructure, you remain responsible for maintaining the signature verification, clock synchronization, and deduplication logic that safeguards your services.
Testing replay protection before it matters
Verify your verification pipeline locally or in staging using automated test runs before deploying handlers to production.
First, test timestamp expiration. Capture a validly signed payload from a staging delivery. Construct a curl request sending that exact body and signature back to your endpoint after 305 seconds have elapsed. Your handler must return an HTTP 401 Unauthorized response indicating a stale delivery, rather than an HTTP 200 or 202.
curl -X POST https://api.yourdomain.internal/webhooks/agentdraft \
-H "Content-Type: application/json" \
-H "X-AgentDraft-Signature: t=1776081600,v1=9b1fec3b5a74e2...d6c7" \
-H "X-AgentDraft-Delivery-Id: del_test_01J8ABCXYZ" \
-d '{"event":"mailbox.message.received","data":{"id":"msg_123"}}'
Second, test idempotency within the freshness window. Send an authentic payload to your endpoint. Assert that the first invocation returns an HTTP 202 Accepted status and queues your background agent job. Immediately re-send the exact same HTTP request. Assert that the second response returns an HTTP 200 OK status with a duplicate response string, and verify that no secondary background job was enqueued.
Third, verify cryptographic integrity by flipping a single byte in the raw JSON payload while leaving the X-AgentDraft-Signature header unchanged. Assert that your receiver returns an HTTP 401 Unauthorized with an invalid_signature status code.
Fourth, simulate a deduplication cache outage. Stop your Redis instance or configure your staging test harness to point to an unreachable port. Assert that your receiver raises explicit health-check alerts rather than silently skipping the deduplication check and executing payloads blindly.
Because the free Developer tier includes signed webhook delivery, you can execute this complete verification test suite in local staging environments without a credit card. Review the AgentDraft pricing page for full details on tier limits and capabilities.
Frequently Asked Questions
What exactly does X-AgentDraft-Signature contain?
The X-AgentDraft-Signature header contains two key-value pairs formatted as t=<unix seconds>,v1=<hex HMAC-SHA256>. The t value is the Unix epoch timestamp when the delivery was initiated, and v1 is the HMAC-SHA256 hex digest calculated over <t>. followed by the raw request body bytes using your workspace secret.
Why is a 300-second timestamp window the recommended default?
A 300-second window balances attack surface reduction with network tolerance. It limits the window an attacker has to replay an intercepted payload to 5 minutes, while providing enough buffer to absorb standard HTTP transport retries, edge routing latency, and minor host clock skew without triggering false rejections.
Does signature verification alone prevent replay attacks?
No. Signature verification only guarantees payload integrity and sender authenticity. It proves that the payload originated from AgentDraft and was not modified in transit. It does not verify freshness or indicate whether your application has previously processed the request. Replay prevention requires signature verification, timestamp checking, and deduplication.
What status code should I return for a duplicate delivery?
Return an HTTP 200 OK status code. Returning an HTTP 200 signals to the delivery system that the message was received, which halts further retries. Returning a 4xx or 5xx error causes upstream systems to treat the transmission as a failure and retry the delivery again.
Does AgentDraft retry webhook deliveries, and how should my handler respond?
Yes, AgentDraft retries webhook deliveries when your receiver returns non-2xx status codes or times out. Your handler should complete signature verification, timestamp validation, and deduplication checks quickly, return an immediate HTTP 202 Accepted (or 200 OK for duplicates), and process the agent workload asynchronously.
AgentDraft's Developer tier is free with no card required: 1 seat, 3 agents, 1 mailbox, 1 connected calendar, 50 bookings a month, and 7-day audit retention. Signed webhook delivery is included on the Developer tier, so a free workspace is a working sandbox for testing webhook handlers. View the full feature comparison and limits at https://agentdraft.io/pricing.