Replay Protection and Rate Limiting
Nonce consumption on agent-to-agent handshakes, the clock tolerances each endpoint applies, and how a replay refusal differs from a rate-limit refusal.
Overview
Replay protection stops a captured request from being accepted a second time. PasskeyBridge enforces it in two places, and they are separate mechanisms with separate storage.
Agent-to-agent handshakes. shield-a2a-handshake requires a client-generated nonce and a timestamp on every state-changing action. The nonce is hashed and inserted into a table with a unique constraint, so the insert itself is the check. This is the mechanism described in the next two sections.
Single-use bindings elsewhere. shield-dpop consumes a binding on first successful verify and answers DPOP_REPLAY afterwards, and rejects a duplicate proof jti with 409. shield-sdk-attestation refuses the same attestation digest twice inside 5 minutes with REPLAY_DETECTED. The hosted passkey relying party deletes a challenge as it claims it, so one challenge can verify exactly once. shield-spatial-bind scores an identical sensor capture as a suspected replay rather than refusing it outright.
Replay protection is not applied to signal ingest. A repeated signal is a legitimate event in that pipeline, and the playbook dedup story is separate.
Who can use this: any tenant on any plan for the handshake and DPoP paths; the A2A lane needs a dashboard session token whose user is a member of the tenant, and the DPoP lane needs an API key with the dpop scope.
Nonce consumption
Every state-changing A2A action carries nonce and timestamp in the request body. initiate, attest, negotiate and renew are all mandatory. status and lookup are read-only and need neither. invalidate is service-to-service only.
The sequence on the server:
- The action is checked against the mandatory list. A mutating action with no nonce is refused before anything else happens.
- The timestamp, when present, is validated against the clock tolerance (next section).
- The nonce is SHA-256 hashed. The raw nonce is never written down.
- The hash is inserted into
shield_a2a_nonceswith the tenant, the action name and an expiry. The table's unique constraint is the whole mechanism: if the insert succeeds the nonce was fresh, and if it fails with a unique violation the nonce was already used.
Generate 32 random bytes and hex-encode them:
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
await fetch("https://api.passkeybridge.io/v1/shield-a2a-handshake", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({
action: "initiate",
tenant_id: tenantId,
initiator_delegate_id: a,
responder_delegate_id: b,
nonce,
timestamp: new Date().toISOString(),
}),
});A successful initiate also returns a server_nonce you can carry into the next step.
Every replay-check failure answers the same way:
HTTP/1.1 409 Conflict
{ "error": "Nonce already consumed—replay detected", "code": "REPLAY_DETECTED" }The error string distinguishes the three cases: a consumed nonce, a missing one (Replay protection required: nonce is mandatory for 'attest' action) and a timestamp outside tolerance (the drift message from the next section). Only the first is a genuine replay.
Nonce lifetime is 5 minutes. Expired rows become eligible for cleanup; the timestamp tolerance, also 5 minutes, is what actually bounds how long a captured request stays interesting.
Only hashes are stored, so the nonce table discloses nothing about the traffic that produced it.
Clock drift validation
Client timestamps are compared against a server clock, and which clock depends on the endpoint.
| Context | Tolerance | Clock read |
|---|---|---|
| Default API operations | 5,000 ms | caller's choice |
Atomic fingerprint capture (shield-spatial-bind) | 500 ms | Postgres clock_timestamp() |
| A2A replay protection | 300,000 ms (5 minutes) | the edge runtime clock |
shield-ntp drift report | 5,000 ms, or 500 ms in strict mode | Postgres clock_timestamp() |
The A2A path reads the edge runtime clock. validateTimestamp compares against Date.now() inside the function isolate rather than the database. At a 5-minute tolerance that difference does not matter in practice, and it is worth knowing when you are reconciling a rejection against a database timestamp.
The spatial and NTP paths read the database clock. clock_timestamp() returns actual wall-clock time at the moment it is called, unlike now(), which returns transaction start. Supabase Postgres runs a disciplined clock, which is why these paths treat it as stratum 2 and the edge runtime as stratum 3.
A rejection names the measured drift:
{ "error": "Clock drift 812ms (client ahead) exceeds 500ms tolerance", "code": "NF08_CLOCK_DRIFT", "drift_ms": 812, "server_time": "2026-09-16T10:00:00.123Z", "tolerance_ms": 500, "stratum": 2, "source": "postgres-clock-timestamp" }Measure your own offset before a strict operation. GET /v1/shield-ntp is public and needs no credential:
curl https://api.passkeybridge.io/v1/shield-ntp{
"server_time": "2026-09-16T10:00:00.000Z",
"server_epoch_ms": 1789000000000,
"stratum": 2,
"source": "postgres-clock-timestamp",
"processing_ms": 12.4,
"protocol": "NTP-over-HTTPS",
"version": "1.0.0"
}POST the same endpoint with client_time or client_epoch_ms, and optionally mode: "strict", and it returns drift_ms, direction, within_tolerance and a recommendation naming the offset to apply. When the database read is unavailable the endpoint falls back to the runtime clock and reports stratum: 3 and source: "edge-runtime-fallback", so you can tell a degraded reading from a disciplined one.
Rate limits on these endpoints
Rate limiting is a separate control from replay protection, and the two are often confused because both refuse a repeated request.
The replay-protected endpoints are limited like everything else: a per-IP layer keyed by function, tenant and a keyed digest of the source IP, and a per-tenant quota for functions that enforce one. shield-a2a-handshake does not add a limiter of its own; shield-dpop enforces the per-tenant quota on bind, verify and nonce. shield-ntp is capped at 120 requests per minute per IP. The public agent verify action is capped at 30 per minute per IP.
The refusals look nothing alike, which is how you tell them apart:
| Condition | Status | Distinguishing field |
|---|---|---|
| Replay or clock drift | 409 | code: REPLAY_DETECTED |
| Per-IP or plan rate limit | 429 | X-RateLimit-Layer header, layer in the body |
| Per-tenant quota | 429 | x-pb-reason: quota-exceeded, error: "tenant_quota_exceeded" |
| Cloudflare edge ceiling | 429 | no x-pb-reason header at all |
A 409 never clears by waiting: the nonce is spent, so generate a new one. A 429 clears after the window named in Retry-After.
Plan defaults, quota numbers and the full set of ceilings are in Rate limits and quotas.
Related from the blog
- How to Benchmark an Identity Verification API: Latency, Freshness, Failure Injectionengineering · 12 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- The 200-Millisecond Blind Spot: What Happens Between SIM Swap and Detectionsecurity · 13 min read