Replay Attacks Against AI Agents: Prevention by Design
Prevent replay attacks against AI agents by binding signatures to request context, mandates, nonce state, idempotency and deterministic verification order.

A replay attack against an AI agent can reuse a perfectly valid signed request. A signature answers a narrower question: did the holder of a particular key sign these bytes? It does not tell you whether those bytes describe the HTTP request that reached your service. It does not prove that the attached mandate belongs to the transaction. It does not prevent the same signed object from being used twice.
That gap is where several ugly enterprise agent attacks live.
Imagine a purchasing agent that is allowed to buy 20 replacement drives from supplier-a.example for up to EUR 4,000. The agent signs a request. An intermediary keeps the signature and changes the destination account, swaps the mandate for a broader one, or replays the valid request after the first order succeeds. Every individual object may still parse. Every signature may still verify. The transaction is still wrong.
The fix is not another identity claim. The verifier must bind identity, authority, request context and mutable usage state into one deterministic decision.
Start with the replay attack surface
Use a concrete request:
POST /v1/purchase-orders HTTP/1.1
Host: supplier-a.example
Content-Type: application/json
OATI-Passport: <signed passport>
OATI-Mandate: <signed mandate>
OATI-Envelope: <signed envelope>
{
"sku": "drive-8tb-enterprise",
"quantity": 20,
"unit_price": "175.00",
"currency": "EUR",
"ship_to": "warehouse:berlin-02"
}
The mandate allows purchase_order.create for that SKU, supplier and destination. It caps the unit price and cumulative budget. The envelope names the transaction, audience and request digest. This looks respectable, but each omitted binding opens a different attack.
Replay
The attacker sends the exact request twice. Signature verification succeeds both times because the bytes have not changed. A timestamp only limits the replay window. It does not make the transaction unique.
Use two replay controls with different jobs:
- A proof nonce prevents the same signed proof from being accepted twice for the same verification key and audience.
- A business idempotency key prevents the same intended operation from executing twice, including when the caller creates a fresh signature for a retry.
These keys need an atomic claim operation. A read followed by a write is vulnerable to concurrent requests.
type ClaimResult = 'claimed' | 'already_claimed' | 'unavailable'
async function claimOnce(key: string, expiresAt: Date): Promise<ClaimResult> {
// Back this with SET NX, a conditional database insert, or equivalent.
// Never implement this as get(key), then set(key).
return replayStore.compareAndSetAbsent(key, expiresAt)
}
Scope the proof replay key to the verification method and audience. Otherwise, a nonce collision across issuers or services can create false denials, while a nonce recorded under the wrong scope can permit reuse.
const replayKey = [
proof.verification_method,
expectedAudience,
proof.nonce
].join('\u0000')
For a material action, unavailable means deny. Treating a broken replay store as an empty replay store converts an outage into an authorization bypass.
Context swap
Suppose the agent signs an envelope that says transaction_id = txn-91, but the body is not included in the signature. An attacker can change the quantity from 20 to 200 while keeping the envelope intact.
Bind the transport request to the envelope with a canonical digest. OATI's current HTTP binding profile uses the uppercase method, path plus query, and a SHA-256 digest of the raw request body:
POST\n
/v1/purchase-orders?region=eu\n
sha256:<raw-body-digest>
Other protocols may bind an origin or selected headers, but that is a different profile. Define normalization and byte encoding once, publish test vectors and identify the profile in the signed proof. Audience verification separately binds the proof to the intended service.
const actualDigest = digestRequest({
method: request.method.toUpperCase(),
pathAndQuery: request.pathAndQuery,
rawBodyDigest: sha256(await request.rawBody())
})
if (!constantTimeEqual(actualDigest, envelope.request_digest)) {
return deny('REQUEST_DIGEST_MISMATCH')
}
Audience checking is part of context binding. A request signed for a test endpoint, regional service or supplier must not work against another endpoint just because both trust the same issuer.
Destination constraints need similar treatment. If ship_to only appears in an unsigned extension, it can be replaced. Put it in signed context and compare it with the mandate's allowed destinations.
Mandate substitution
Now assume the body and envelope are correctly bound. An attacker attaches a different, valid mandate issued to the same agent. The substitute has a higher budget or includes another supplier.
The envelope must identify the exact mandate, and preferably bind its canonical digest. The verifier then checks that the mandate subject matches the Passport subject and that the mandate issuer is accepted for the accountable organization.
{
"transaction_id": "urn:oati:txn:01K9A7...",
"agent_id": "oati:agent:buyer:procurement-7",
"mandate_id": "oati:mandate:buyer:po-8841",
"mandate_digest": "sha256:3a6f...",
"audience": "https://supplier-a.example",
"request_digest": "sha256:91e8...",
"issued_at": "2026-08-10T08:31:12Z",
"nonce": "8d250d63-6f91-49f8-a0f7-94f58f4b1831"
}
Do not select authority by searching for any mandate that can satisfy a request. Verify the mandate the transaction explicitly names. If the ID or digest differs, deny.
Delegation adds another substitution path. A child agent may present a legitimate child mandate while hiding its parent. Verify the complete delegation chain and prove that each child is equal to or narrower than its parent. Missing constraints must not mean unlimited authority.
For set constraints, subset checks are straightforward. Numeric ceilings must decrease or remain equal. Expiry cannot extend beyond the parent. Delegation depth must decrease. Purpose, destination and counterparty restrictions must survive every hop.
function assertChildSubset(parent: Mandate, child: Mandate) {
assert(isSubset(child.actions, parent.actions))
assert(isSubset(child.resources, parent.resources))
assert(isSubset(child.counterparties, parent.counterparties))
assert(isSubset(child.destinations, parent.destinations))
assert(child.limits.max_amount <= parent.limits.max_amount)
assert(child.expires_at <= parent.expires_at)
const remainingDepth = parent.delegation.max_depth - 1
assert(child.delegation.max_depth <= remainingDepth)
}
Real evaluators also need clear semantics for wildcards, absent fields and structured resources. A string-prefix comparison for resource identifiers is rarely enough.
Verification order is a security property
A reliable gateway performs checks in an explicit order:
- Enforce size limits, then parse and validate schemas.
- Resolve issuer chains to configured trust anchors.
- Check key validity, status and revocation.
- Recreate canonical signing payloads and verify signatures.
- Check activation, expiry, proof age and audience.
- Recalculate the request digest from the received HTTP request and reject a mismatch.
- Claim the proof nonce atomically.
- Match agent, Passport, mandate and envelope identifiers.
- Verify the delegation chain and non-amplification.
- Evaluate action, resource, purpose, counterparty and destination.
- Reserve budget, call count and one-time usage atomically.
- Execute the upstream operation and issue a signed receipt.
Why claim replay state before policy evaluation? Because verification is an attempt to use the signed proof. If you only store a nonce after successful execution, concurrent requests can both cross the verification boundary. First reject malformed input, invalid signatures and request-binding mismatches so an attacker cannot burn a legitimate nonce with an altered body. Once the authentic proof matches the received request, claim its nonce before authorization continues.
Usage reservation has a harder failure mode. A gateway reserves the last permitted call, forwards the request, then loses the response. Releasing the reservation may allow a duplicate action. Keeping it may consume authority even if the upstream never acted.
For consequential writes, the safer default is to keep the reservation in an uncertain state and reconcile it. Availability loses to non-duplication.
AUTHORIZED -> RESERVED -> SENT -> CONFIRMED
|
+-----> UNCERTAIN -> RECONCILED
Treat idempotency and replay as separate controls
Replay protection rejects reuse of a cryptographic proof. Idempotency returns or reconstructs the result of a business operation that the client intentionally retries. Mixing them produces bad failure handling.
A client may time out and retry the same purchase with a fresh proof nonce. The replay check should pass because this is a new proof. The idempotency layer should recognize the business key and return the existing order result instead of creating another order.
Store the idempotency key with a fingerprint of the normalized operation. If the same key arrives with a changed amount or destination, return a conflict, not the earlier result.
INSERT INTO operation_claims (
tenant_id, idempotency_key, request_digest, state
) VALUES ($1, $2, $3, 'reserved')
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING;
Then compare request_digest on conflict. An idempotency key is not a general permission to alter a pending command.
Build an attack fixture, not only a happy-path test
One signed transaction can generate a useful adversarial suite:
| Mutation | Expected result |
|---|---|
| Submit identical proof twice | REPLAY_DETECTED |
| Fresh proof, same business key and body | existing result or pending status |
| Same business key, changed amount | idempotency conflict |
| Change HTTP method or target | request digest mismatch |
| Change body whitespace only | same digest if canonical JSON is defined |
| Change quantity or destination | request digest mismatch |
| Present envelope to another audience | audience mismatch |
| Attach another valid mandate | mandate ID or digest mismatch |
| Remove parent constraint in child | non-amplification failure |
| Use mandate after revocation | revoked target denial |
| Race 64 copies of one nonce | exactly one proof claim succeeds |
| Disable replay storage | material action fails closed |
Fuzz canonicalization boundaries too. Duplicate JSON keys, Unicode normalization, extreme number forms and ambiguous URLs have caused signature systems trouble for years. A conformance suite should make every implementation produce the same bytes and reason codes.
Related engineering guides
- Put prevention on the MCP authorization path.
- Apply the same nonce and idempotency separation to accounts payable automation.
What OATI supports today
The OATI developer preview includes RFC 8785/JCS canonicalization, Ed25519 and ES256 verification profiles, audience and replay checks, target-specific revocation, deterministic Mandate evaluation, delegation subset proofs, request-digest binding and shared conformance vectors. The TypeScript reference middleware requires request binding by default. Python and Go implement the portable core against the same language-neutral suite.
That is useful implementation material, but it is not a claim of completed production assurance. Independent cryptographic and protocol review remains open. Production outage drills, prolonged race and contention testing, and the durable evidence and dispute service are not complete release gates yet.
Implementation checklist
- Define one canonical representation for every signed object and HTTP request.
- Bind the HTTP method, path plus query and raw-body digest according to the selected profile; verify audience separately.
- Bind the exact Passport, mandate and delegation chain to the envelope.
- Resolve current key and revocation state before authorization.
- Use an atomic replay claim scoped by key, audience and nonce.
- Keep replay nonce and business idempotency semantics separate.
- Store an operation fingerprint with every idempotency key.
- Make child authority a deterministic subset of parent authority.
- Reserve budgets and one-time use with compare-and-set semantics.
- Keep uncertain executions reserved until reconciliation.
- Fail closed when replay, trust or usage state is unavailable for material actions.
- Test mutations, races, stale caches and partial failures.
- Sign a receipt that binds the request digest, policy decision and execution result.
A verifier needs the signed request context, intended audience, exact authority and current usage state before it can authorize the transaction.