HMAC Proves Origin, Not Freshness: Replay Attacks Against Signed APIs
The signature verified. The secret was correct. The payment processed for the fourth time, from a request captured three hours earlier.
HMAC proves that a request came from someone who knows the secret. It does not prove when. APIs that sign requests without including a timestamp in the signed payload accept valid replays indefinitely. One captured request becomes a standing credential for that action.
HMAC Signs the Message, Not the Moment
HMAC-SHA256 is deterministic: same key plus same message equals the same MAC, always. That property is the mechanism's strength for origin authentication. It is also why replay is possible when no freshness field exists in the signed payload.
Signature verification confirms two things: that the sender holds the secret, and that the message was not altered in transit. It does not confirm when the message was generated, and it does not confirm whether the message was processed before. These are properties of authenticity and integrity, not freshness.
Without a timestamp or nonce in the signed payload, the server has no field to inspect and validate. The same HMAC generated yesterday is valid tomorrow. MITRE classifies this exact pattern as CWE-294 (Authentication Bypass by Capture-replay): the attacker captures valid credentials or requests and retransmits them to bypass authentication. Every API that signs requests without including freshness in the signed string applies this pattern by omission.
Payment Replay: One Captured Request, Unlimited Charges
HackerOne #996540 documented a vulnerability in RBKmoney where Apple Pay cryptograms were accepted for repeated charges against any merchant. The Apple Pay cryptogram contains onlinePaymentCryptogram, transactionId, and a signature. Apple warns that cryptograms older than a few minutes are replay candidates. RBKmoney skipped the timing check.
HackerOne #56800 (Shopify) recorded a different failure: the checkout webhook lacked HMAC verification on the receiving side. Payment events could be resent without any signature challenge. The two cases represent distinct failures: RBKmoney accepted expired signed payloads; Shopify accepted payloads with no signature at all. The practical result is identical.
The attack requires no privileged access to the communication channel. A compromised CDN log, a TLS misconfiguration, or an insider with access to request logs is enough to capture the signed payload. Capture is offline: the attacker collects the request, stores it, and resends it when convenient. Each resend triggers the same action because the signature remains valid. The server never knows it is processing the same request for the second, third, or tenth time.
CVE-2013-4346 (python-oauth2) established this pattern in 2013: OAuth nonces were not validated, making signed OAuth requests replayable. The same omission keeps appearing across generations of API authentication libraries.
Webhook Replay: Signed Events That Deliver Twice
Webhooks solve real-time event delivery: the platform signs each event and sends it to the receiving server. The signature proves the event came from the platform. It does not prevent the same event from being delivered again.
CVE-2026-3109 (Mattermost Zoom plugin, CVSS 2.2) was published in March 2026. The plugin's webhook handler did not validate timestamps. An attacker with access to a captured Zoom request can resend it to corrupt meeting state in Mattermost. Mattermost fixed this in versions 11.5.0 and 10.11.12.
Stripe's model is the industry reference for webhook replay prevention. The Stripe-Signature header includes t= as part of the signed payload, not just as a separate HTTP header. This is critical. A timestamp in the header but outside the signed string can be modified freely without invalidating the signature. Stripe rejects deliveries where the gap between the signed timestamp and server time exceeds 300 seconds.
Non-idempotent endpoints are the highest-risk targets: order confirmation webhooks, payment confirmation webhooks, user creation events, privilege grant events. Webhook replay is operationally simpler than network interception. The attacker works entirely at the HTTP layer against the receiving server, with no need to be on the communication path between the platform and the server.
Four CVEs That Skipped Freshness Checks
Missing timestamp validation has produced confirmed CVEs in production S3 gateways, LMS platforms, enterprise authentication systems, and collaboration tools. This is not a theoretical risk.
| CVE | Product | CVSS | Mechanism |
|---|---|---|---|
| CVE-2025-68671 | lakeFS S3 gateway | 6.5 | Signed S3 requests accepted without timestamp range validation |
| CVE-2026-53636 | Open edX LTI provider | 4.7 |
validate_timestamp_and_nonce validated neither |
| CVE-2025-42959 | SAP HMAC auth | 8.1 | HMAC credentials from unpatched system replayed against patched targets |
| CVE-2026-3109 | Mattermost Zoom plugin | 2.2 | Webhook handler accepted stale timestamps |
CVE-2025-68671 affects the lakeFS S3 gateway through v1.74.4. The gateway accepts signed S3 requests without validating the timestamp range. Captured credentials remain valid until the key is rotated, which may be weeks or months after capture. The fix arrived in v1.75.0.
CVE-2026-53636 (Open edX, CVSS 4.7) carries the most ironic function name on the list. The function validate_timestamp_and_nonce in lms/djangoapps/lti_provider/signature_validator.py validated neither timestamps nor nonces. LTI launch requests were replayable without limit. The fix is commit 3a5ac85. The CVE was published September 2, 2026.
CVE-2025-42959 (SAP, CVSS 8.1, CWE-294) is the most severe case. This is a compound CVE: credential exposure is required before replay is possible. An unauthenticated attacker extracts HMAC credentials from an unpatched SAP system. Those credentials are replayed against separate patched SAP systems. Cross-system replay enables full compromise without valid credentials on the target. The attack vector is network-based and requires no user interaction.
AWS SigV4 Versus Naive HMAC
AWS Signature Version 4 exists precisely to close the gaps that naive HMAC leaves open. The SigV4 canonical request binds the signature to four elements that naive HMAC omits.
First, x-amz-date: the timestamp is part of the canonical string and the validity window is 15 minutes. Requests older than 15 minutes from server time are rejected regardless of signature validity. Second, the SignedHeaders parameter lists which headers are bound to the signature. Host, x-amz-date, and x-amz-content-sha256 are mandatory. Any header not in the list can be modified freely without invalidating the signature. Third, the SHA-256 hash of the request body is part of the canonical request. Changing the body breaks the signature.
Naive HMAC typically signs the body only. Date, host, and body hash stay outside the signed string. Without a date: replay is indefinite. Without a host: the same signed request can be resent against different endpoints. Without a body hash: the body can be swapped for any content while the signature stays valid. The distance between SigV4 and naive HMAC is exactly the space that replay attacks occupy.
The Fix: Timestamp in the Signed Payload, Nonce in Redis
Replay protection requires two independent controls: a short timestamp window that bounds the attack surface in time, and a per-request nonce tracked server-side that eliminates replay even within that window.
The first control is including the Unix timestamp in the signed string, not just as a separate HTTP header:
HMAC-SHA256(secret, method + path + body_hash + timestamp + nonce)
The server calculates abs(server_now - timestamp) and rejects any request where that value exceeds 300 seconds. This 5-minute tolerance matches Stripe's default in production. Clock drift is handled by NTP: the server must use NTP, and any client that drifts beyond 5 minutes has a separate operational problem.
The second control is the nonce. Each request generates a UUID or CSPRNG nonce. The server runs SET NX in Redis with TTL equal to the tolerance window. If the nonce already exists in Redis, the request is rejected immediately, even if the timestamp is valid. This eliminates replay within the 300-second window.
The third control is idempotency keys on write endpoints: POST /payments, POST /webhooks/receive. Already-processed event IDs are stored in Redis with TTL matching the replay window. This control operates at the application layer and protects against replays arriving through paths not covered by signature controls.
The MAGO Intel tool (intel.mago.team) identifies signed API endpoints lacking timestamp validation during API security assessments. These are the same vectors that produced CVE-2025-68671 and CVE-2026-53636 in production systems.
Auditing signed API endpoints is not about breaking the HMAC. It is about asking whether a signed timestamp exists in the request and whether the server actually validates it. Those are two separate questions, and most codebases fail the second.
Top comments (0)