Preventing Replay Attacks: A Developer's Guide to Agentic Email Webhook Security
Your agent's webhook handler is probably replayable. Here's how to verify the signature, reject stale timestamps, and make the handler idempotent so a duplicate delivery can't send the same email or book the same slot twice.
A webhook signature proves who sent a payload, but it does not prove the payload is fresh. To achieve robust agentic email webhook replay protection, your endpoint must execute three distinct controls in sequence: verify the cryptographic signature over the unparsed request body, reject timestamps older than a strict tolerance window, and enforce an idempotency key in the storage transaction that executes the agent's side effect.
When an autonomous agent handles email webhooks, a duplicated HTTP delivery causes real-world damage. The agent does not merely update a cached record in memory. It drafts replies, issues calendar bookings, or calls external APIs. Without deliberate safeguards, retries from the webhook dispatcher or replayed captured requests will duplicate downstream operations.
The replay window is the bug, not the signature
When an agent framework processes an inbound email webhook, network timeouts or transient handler latency cause dispatchers to retry delivery. If an upstream service retries an event because your server took 3,500 milliseconds to invoke an LLM, the handler runs twice. An autonomous agent will draft a second outbound message, fire off an external payment webhook, or commit duplicate calendar slots.
A valid hash-based message authentication code (HMAC) signature establishes authenticity and integrity. As explained in the GitHub Docs on validating webhook deliveries, comparing a digest of the payload against a shared secret confirms the bytes arrived unaltered from the claimed sender. However, a captured, validly signed HTTP request remains cryptographically valid for the lifetime of that secret key. If an attacker intercepts the packet or a network partition causes the webhook provider to redeliver the payload three minutes later, the signature check still passes.
Building reliable agentic email webhook replay protection requires three distinct barriers applied in an exact operational sequence:
- Cryptographic authenticity: Verify the keyed hash of the raw, untouched request body before parsing the JSON.
- Freshness window: Reject any message carrying a timestamp outside a narrow allowable window (300 seconds).
- Storage-level idempotency: Use a persistent idempotency key inside the handler's write transaction to ensure that legitimate in-window retries execute as immediate no-ops.
The concrete artifacts you must handle are the signature header (such as X-AgentDraft-Signature), an enforced freshness ceiling, and atomic conditional checks stored alongside the action's result.
What X-AgentDraft-Signature actually contains
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 structure of the signed string prevents tampering with either the timestamp or the payload. The sender builds the HMAC input by concatenating the UNIX epoch timestamp integer, a single ASCII period delimiter, and the exact bytes of the HTTP request payload:
signed_payload = t + "." + raw_request_bodyUsing an HMAC-SHA256 keyed hash binds the request content directly to your workspace secret, a design pattern examined by NXT Banking's analysis of HMAC signature verification and formalized in IETF RFC 2104 on HMAC keyed-hashing. Two subtle engineering errors routinely break this verification in production agent systems:
First, developers frequently allow web application frameworks (such as Express, FastAPI, or NestJS) to parse the JSON request body prior to verification. If a framework parses JSON into an object and your verifier calls JSON.stringify() to rebuild the bytes, verification fails. Key reordering, whitespace changes, or escaped unicode characters yield a different SHA-256 hash. The signature must be computed directly against the unparsed byte buffer read off the TCP socket.
Second, developers compare the computed hexadecimal digest using standard string equality operators (such as == or ===). Standard string comparisons exit early on the first non-matching byte, leaking timing information that allows an adversary to recover the signature byte by byte. Handlers must use a constant-time comparison helper like Node.js's crypto.timingSafeEqual() or Python's hmac.compare_digest(), mitigating side-channel risks outlined in CWE-208: Observable Timing Discrepancy.
Workspace signing secrets should be treated like private keys. If you rotate a signing secret in your dashboard, all in-flight retries from earlier deliveries will fail validation. Perform credential rotations during documented quiet maintenance windows.
Rejecting stale timestamps: the 300-second rule
The cryptographic signature verifies who authored the bytes, but without checking the timestamp component, it offers no proof that the message was dispatched in the active window. A network eavesdropper or an unauthorized entity with access to ingestion logs could capture an HTTP POST request and resend it an hour later. The receiver enforces timestamp validation, not the upstream sender. If your handler ignores the t parameter, a captured delivery remains actionable indefinitely.
When handling inbound payloads, parse the UNIX timestamp from the t= segment of the header and compare it directly to your system's clock. The difference between the local system clock and t must fall within a 300-second threshold:
current_time = unix_now()
if (current_time - t) > 300:
return 400, "Timestamp too old"
if (t - current_time) > 15:
return 400, "Timestamp too far in the future"Allowing a 15-second negative drift accounts for slight clock skew between the sender's infrastructure and your receiver without creating an exploitable forward window. A 300-second window is long enough to absorb standard exponential retry backoffs, transient network routing latency, and reasonable NTP clock drift. Simultaneously, it is short enough to limit the window of opportunity for an adversary replaying captured raw packets.
However, an attacker or a redundant network path can replay a valid packet within that 300-second window. The timestamp check alone passes because the delivery is only 45 seconds old. To close this gap, store observed delivery identifiers or cryptographic signatures in a high-speed memory cache with a 300-second Time-To-Live (TTL). If an incoming signature exists in the cache, reject it immediately as a duplicate delivery.
Idempotency keys for webhooks: making the second delivery a no-op
Signature validation and freshness checks protect your agent against malicious packet tampering and expired captures. They do not prevent standard webhook delivery retries within the active freshness window. In distributed webhooks, at-least-once delivery is the standard baseline guarantee. If your server processes an agentic email, commits a calendar booking, but takes 10.1 seconds to respond to an endpoint that times out at 10.0 seconds, the sender assumes the payload was dropped and resends it.
Your receiver must convert at-least-once network delivery into exactly-once execution. Architectural specifications, such as the IETF Idempotency-Key HTTP Header specification, establish that distributed endpoints must track deterministic transaction tokens to absorb repeated transmissions safely.
Do not generate an idempotency key using client-side runtime values like random UUIDs or local invocation timestamps. The idempotency key must be derived deterministically from the inbound payload's immutable fields. A reliable key construction combines the event ID with the event type:
idempotency_key = "idemp:" + event_type + ":" + event_idWhen implementing coordination logic across multiple systems, storing and verifying this idempotency key must happen inside the same database transaction that registers the agent's side effect. If using a relational database, insert the idempotency key into a tracking table using a unique constraint:
BEGIN;
INSERT INTO processed_webhooks (idempotency_key, processed_at)
VALUES ('idemp:email.received:msg_8f92b', NOW());
-- Execute internal state transitions here
COMMIT;If the key already exists, the database aborts the transaction with a unique constraint violation. When using key-value stores or document databases, execute an atomic conditional write (such as a PutItem carrying an attribute_not_exists condition expression).
Crucially, return HTTP 200 OK when rejecting a duplicate idempotency key. If your handler returns HTTP 409 Conflict, 429 Too Many Requests, or 500 Internal Server Error, the webhook dispatcher assumes your system failed to ingest the payload and schedules another retry. Returning HTTP 200 tells the upstream dispatcher that the event has been safely handled, halting the retry loop immediately.
Why duplicate agent actions are worse than duplicate HTTP requests
In standard microservice architectures, an unhandled duplicate HTTP request usually costs very little. An idempotent SQL UPDATE over a user's address field overwrites identical strings without disrupting users. Autonomous agents behave differently: their primary job is to interact with external environments via tools.
When an LLM agent processes a duplicated inbound webhook, it can take irreversible, consequential real-world actions:
- Unintended external messaging: An agent parsing an inbound email twice might generate two distinct outbound replies with slightly different phrasing, confusing clients and exposing internal system instability.
- Calendar slot collisions: The agent might issue a secondary booking request for a prospect, holding open extra capacity or conflicting with other scheduled tasks. AgentDraft coordinates holds and commits through a priority-aware conflict engine so multiple agents can act on the same calendar without double-booking.
- Quota exhaustion: Unchecked loops burning LLM context windows and API allocations will trigger third-party rate limits, taking your systems offline.
To restrict the blast radius of runaway agent processes, production systems isolate credentials and network boundaries per agent. An AgentDraft mailbox is an addressable inbox owned by one agent; giving each agent its own mailbox isolates blast radius, so one runaway agent exhausts its own quota rather than the whole sending domain. Mailboxes are counted per workspace, so per-agent isolation only goes as far as the plan's mailbox count.
AgentDraft's free Developer tier includes one mailbox, which the workspace owner can move between its 3 agents; giving several agents their own inbox at the same time needs Team (5 mailboxes) or above. Isolating an agent to a distinct mailbox ensures that an edge-case retry loop within a customer support agent cannot lock out your sales scheduling agent's outbound domain reputation.
When duplicate executions slip through application-layer flaws, an immutable system record is your only recovery tool. AgentDraft records state-changing agent actions in an append-only audit trail. Having an append-only event stream lets engineers trace precisely which webhook triggered an errant outbound message and verify when the state transition occurred.
A handler that survives a replay, end to end
A production-ready handler must verify signatures, validate freshness, check idempotency, and execute effects without race conditions. Below is the operational flow implemented in standard pseudo-code, easily adapted to frameworks such as LangChain, CrewAI, AutoGen, or an n8n webhook routing node:
import hmac
import hashlib
import time
from database import db_transaction, find_idempotency_key
WEBHOOK_SECRET = get_env("AGENTDRAFT_WEBHOOK_SECRET")
def handle_inbound_webhook(request):
# Step 1: Extract the unparsed, raw byte array directly from the socket
raw_body = request.get_raw_body_bytes()
sig_header = request.headers.get("X-AgentDraft-Signature")
if not sig_header:
return response(status=401, body="Missing signature header")
# Step 2: Parse t and v1 components
parsed_header = parse_signature_header(sig_header)
t = parsed_header.get("t")
v1 = parsed_header.get("v1")
if not t or not v1:
return response(status=400, body="Malformed signature header")
# Step 3: Enforce the 300-second freshness window
current_time = int(time.time())
delivery_timestamp = int(t)
# Reject messages older than 300s or drifting further than 15s in the future
if (current_time - delivery_timestamp) > 300:
return response(status=400, body="Payload timestamp expired")
if (delivery_timestamp - current_time) > 15:
return response(status=400, body="Payload timestamp out of bounds")
# Step 4: Recompute HMAC-SHA256 and evaluate with constant-time equality
expected_payload = f"{t}.".encode("utf-8") + raw_body
computed_digest = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
expected_payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison prevents side-channel timing attacks
if not hmac.compare_digest(computed_digest, v1):
return response(status=401, body="Signature verification failed")
# Step 5: Parse JSON only after verification succeeds
payload = parse_json(raw_body)
event_id = payload.get("id")
event_type = payload.get("type")
idempotency_key = f"evt:{event_type}:{event_id}"
# Step 6: Atomically check idempotency and record the side effect
with db_transaction() as tx:
if tx.record_exists("processed_events", key=idempotency_key):
# Acknowledge with 200 so upstream halts retries; do not re-run agent
return response(status=200, body="Duplicate delivery acknowledged")
# Mark key as processed within the same atomic transaction
tx.insert("processed_events", key=idempotency_key, created_at=current_time)
# Dispatch side effect to agent queue or execution pipeline
enqueue_agent_task(payload)
# Step 7: Return HTTP 200 only after persistence is durable
return response(status=200, body="Event accepted")Returning an HTTP 200 response before database persistence completes creates an operational data loss scenario. If your process crashes between returning HTTP 200 and persisting the state to your transactional store, the webhook service treats the payload as acknowledged and ceases delivery attempts for that event.
Failure modes that look like replay attacks but aren't
When debugging webhook ingestion errors, several distinct architectural flaws mimic the symptoms of an active replay attack. The table below lists common production failure modes, their mechanical causes, and concrete diagnostic indicators:
| Failure Mode | Mechanical Cause | Log Diagnostic | Remediation |
|---|---|---|---|
| Receiver Clock Drift | Server hardware clock drifts from true UTC, causing valid incoming timestamps to exceed the threshold. | 400 Bad Request: Payload timestamp expired on every inbound event. | Synchronize system clocks via NTP (such as chrony or systemd-timesyncd). Do not expand the freshness window. |
| Body Mutation by Middleware | Framework middleware re-serializes payload or trims trailing whitespace before signature validation. | 401 Unauthorized: Signature verification failed despite using matching workspace secrets. | Capture raw request buffer off the network socket before parsing JSON or running body parsers. |
| Handler Timeout Storms | Agent performs synchronous LLM inference inside the webhook thread, exceeding the sender's timeout. | Sender dashboard displays batches of retried deliveries for identical event_ids at fixed intervals. | Acknowledge event to the database queue within 500ms; offload execution to background worker processes. |
| True Replay Attack | Intercepted HTTP request re-transmitted to the receiver using identical headers and body bytes. | 400 Payload timestamp expired or immediate Duplicate delivery acknowledged log hit. | Enforce 300-second freshness window and database-level idempotency key checks. |
Testing replay protection without a second agent
You do not need a secondary mock agent framework or third-party traffic generation services to validate your webhook security controls. You can verify your ingestion endpoint directly using curl and saved production payloads. 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. Developer-tier outbound email must reply within a booking thread and is capped at 5 sends per agent per day. Signed webhook delivery is included on the Developer tier, so a free workspace is a working sandbox for testing webhook handlers.
Run these three test cases sequentially against your test or local environment:
Test 1: Identical Byte Replay (Testing Idempotency)
Capture a valid incoming delivery using an HTTP inspector. Using the original headers (including unchanged X-AgentDraft-Signature) and untouched body bytes, issue an identical POST request within 60 seconds of generation:
curl -X POST https://api.yourdomain.com/webhooks \
-H "Content-Type: application/json" \
-H "X-AgentDraft-Signature: t=1776000000,v1=a4f1...5c2b" \
--data-binary @raw_payload.jsonYour endpoint must return HTTP 200 OK. Verify your database records to confirm the agent executed the downstream side effect exactly once.
Test 2: Expired Timestamp Injection (Testing Freshness)
Re-send the captured payload with the t= parameter modified to a timestamp 301 seconds in the past, or wait six minutes and fire the exact curl command from Test 1 without modification.
Your endpoint must reject the request with an HTTP 400 Bad Request error. Check your logs to confirm the error explicitly reports an expired delivery timestamp.
Test 3: Payload Tampering (Testing Cryptographic Integrity)
Keep the valid timestamp and signature header from Test 1, but append a single space character to the end of the JSON body or alter a single letter in an email subject field.
Your endpoint must reject the request with HTTP 401 Unauthorized. The rejection must trigger during HMAC validation, before schema validation or business logic executes.
Where replay protection sits in the wider agent security model
Replay protection secures the boundary where external events enter your infrastructure. However, a secure webhook handler represents only one layer in a complete defense-in-depth model for autonomous agents. Replay protection, scoped credentials, and human authorization gates address distinct threats across an agent's lifecycle.
Even if an attacker bypasses replay defenses, scoped authorization keys restrict the blast radius of unauthorized actions. 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. Restricting API keys prevents an agent managing an inbound support mailbox from modifying unrelated calendar coordination rules.
For high-risk operations, automated mechanisms must yield to manual oversight. 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.
Approvals are decided in the AgentDraft dashboard. AgentDraft emails the workspace owner a notification linking to the queue, but the decision itself is made signed in — there are deliberately no approve-from-email links, because an unauthenticated one-click approve is an attack surface. Slack, Discord, Teams, SMS and push delivery are not available today. If an inbound replay triggers an approval-gated flow, the action pauses safely in the dashboard queue until a human reviews the context.
Finally, your audit pipeline must retain a complete chronological record of all agent activity. AgentDraft records state-changing agent actions in an append-only audit trail. Audit retention is per-tier and enforced on read as well as on write, so the retention claim holds even though deletion is lazy.
Conclusion: verify, expire, deduplicate
Reliable agentic email webhook replay protection relies on applying three distinct controls in strict sequence:
- Verify first: Calculate an HMAC-SHA256 signature over the raw body and compare it using constant-time equality.
- Expire second: Enforce a strict 300-second freshness window using the header's timestamp.
- Deduplicate third: Persist an event-derived idempotency key alongside the action's side effects in a single database transaction.
To view the header format, payload examples, and implementation details for signature verification, consult the AgentDraft Webhooks documentation. Every user-visible API update and header change lands in the public AgentDraft changelog.
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. Developer-tier outbound email must reply within a booking thread and is capped at 5 sends per agent per day. Signed webhook delivery is included on the Developer tier, so a free workspace is a working sandbox for testing webhook handlers. Review plan specifications and resource limits for AgentDraft at https://agentdraft.io/pricing.
Frequently Asked Questions
Does a valid HMAC signature prevent webhook replay attacks?
No. A valid HMAC signature verifies message authenticity and integrity. It proves the payload originated from the holder of the shared secret and was not altered in transit. Because HMAC signatures do not expire on their own, a captured, validly signed request can be re-transmitted indefinitely unless the receiver enforces timestamp expiration checks and idempotency tracking.
Why 300 seconds and not 30 or 3600?
A 300-second (5-minute) freshness window balances security against network reliability. A very tight 30-second window risks rejecting legitimate deliveries during transient cloud network latency spikes, dispatch retries, or minor server clock drift. Conversely, an hour-long window leaves an excessively wide opportunity for captured packet replays. Five minutes provides sufficient operational tolerance while keeping the replay surface bounded.
What should a webhook handler return for a duplicate delivery?
A webhook handler should return an HTTP 200 OK status code when it encounters a duplicate delivery that has already been recorded. Returning HTTP 200 signals to the dispatcher that the payload was received, halting further retries. Returning a 4xx or 5xx code causes the upstream dispatcher to mark the delivery as failed and schedule another retry, worsening duplicate traffic.
Can I use the delivery ID as an idempotency key?
Yes. A delivery ID or event ID provided in the webhook payload is an ideal base for an idempotency key because it is unique to the event and remains identical across automatic delivery retries. For maximum safety, prefix the event ID with the event type (such as idemp:email.received:evt_12345) to prevent accidental key collisions across different event streams.
Does AgentDraft reject expired timestamps before delivering webhooks?
No. The receiver enforces timestamp validation, not AgentDraft. AgentDraft signs the payload and includes the dispatch timestamp in the X-AgentDraft-Signature header. The receiver must inspect that timestamp and reject deliveries older than 300 seconds (5 minutes), as outlined in the reference verifier documentation at agentdraft.io/for-agents/webhooks.