The most surprising truth about preventing replay attacks is that the attacker doesn’t need to understand your system’s logic at all; they just need to intercept and resend valid messages.
Let’s say you have a simple API endpoint that allows users to transfer funds. A legitimate request might look like this:
{
"action": "transfer",
"from_account": "user123",
"to_account": "merchant456",
"amount": 10.50,
"currency": "USD"
}
An attacker intercepts this. They can’t forge a new request because they don’t have the user’s credentials or know the internal account IDs. But they can simply copy the entire JSON and resend it to your server. If your server processes this identical request again, user123 might end up sending 10.50 USD to merchant456 twice. This is a replay attack.
To prevent this, we introduce several mechanisms, often used in combination.
Nonces (Numbers Used Once)
A nonce is a unique, unpredictable number that is generated for each transaction. The server keeps a record of recently used nonces. If a nonce is received that has already been processed, the server rejects it.
Imagine our request now includes a nonce:
{
"action": "transfer",
"from_account": "user123",
"to_account": "merchant456",
"amount": 10.50,
"currency": "USD",
"nonce": "a3b1c9d7e5f2"
}
The server would store "a3b1c9d7e5f2" in a set of used nonces for a short period (e.g., 5 minutes). If the same nonce appears again within that window, the server knows it’s a replay.
Diagnosis: You’d typically see logs indicating duplicate nonces being rejected. On the server, you’d check your nonce cache (e.g., Redis, an in-memory map) for the presence of the repeated nonce.
Fix: Ensure your nonce generation is cryptographically secure (e.g., using crypto.randomUUID() in Node.js or secrets.token_hex(16) in Python). The server-side logic should atomically check if the nonce exists and add it to the cache. If it already exists, reject the request with a 409 Conflict or a specific error code like ERR_NONCE_ALREADY_USED. The cache needs a Time-To-Live (TTL) to prevent indefinite growth and to allow for legitimate retries after the window expires.
Why it works: Each message gets a unique identifier. The server acts as an arbiter, refusing to process any message with an identifier it has seen recently.
Timestamps
Timestamps add a temporal element. Each request includes the current time. The server rejects any request that is too old or too far in the future.
Our request with a timestamp:
{
"action": "transfer",
"from_account": "user123",
"to_account": "merchant456",
"amount": 10.50,
"currency": "USD",
"timestamp": 1678886400
}
The server checks if timestamp is within a defined tolerance window, say, current_time - 300 seconds to current_time + 60 seconds. If the timestamp is outside this window, the request is considered invalid.
Diagnosis: Logs showing ERR_TIMESTAMP_OUT_OF_SYNC or ERR_REQUEST_TOO_OLD. On the server, you’d check the system clock and the tolerance window configuration.
Fix: Ensure both client and server clocks are synchronized using NTP (Network Time Protocol). The server-side validation logic should compare the request’s timestamp against the server’s current time, allowing for a small delta (e.g., 300 seconds for "too old" and 60 seconds for "too new"). Requests outside this window are rejected.
Why it works: It prevents attackers from replaying old messages by enforcing a freshness requirement. It also guards against clock skew issues by allowing a small buffer.
HMAC (Hash-based Message Authentication Code)
HMAC provides both integrity and authenticity. It uses a shared secret key between the client and server. The sender computes a hash of the message and the nonce/timestamp using the secret key. The receiver recalculates the HMAC using the same message, nonce/timestamp, and secret key to verify it.
Here’s a request with an HMAC:
{
"action": "transfer",
"from_account": "user123",
"to_account": "merchant456",
"amount": 10.50,
"currency": "USD",
"timestamp": 1678886400,
"nonce": "a3b1c9d7e5f2",
"hmac": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
The hmac field is generated by hashing action=transfer&from_account=user123&to_account=merchant456&amount=10.50¤cy=USD×tamp=1678886400&nonce=a3b1c9d7e5f2 using an algorithm like SHA-256 and a secret key known only to the client and server.
Diagnosis: Logs showing ERR_INVALID_HMAC or ERR_SIGNATURE_MISMATCH. This means the HMAC calculated by the server using the received payload and the shared secret did not match the hmac value sent with the request.
Fix:
- Key Management: Securely provision and manage shared secret keys. Never hardcode them. Use environment variables or a secrets manager.
- Message Formatting: Define a strict, canonical format for the message payload to be hashed (e.g., sorting keys alphabetically, using consistent delimiters).
- HMAC Calculation: On the server, extract the payload (excluding the
hmacfield itself), reconstruct the message string in the canonical format, and compute the HMAC using the same algorithm (e.g., HMAC-SHA256) and the shared secret key. Compare this computed HMAC with the one received. If they don’t match, reject the request. - Algorithm: Use a strong hashing algorithm like SHA-256 or SHA-512.
Why it works: If an attacker tries to replay a message or tamper with its contents (even just changing the amount), the HMAC will no longer match the computed hash, because the hash depends on all parts of the message and the secret key. This ensures both that the message hasn’t been altered and that it originated from a party possessing the secret key.
Combining Them
In practice, you’d use nonces and timestamps and HMACs. The nonce prevents immediate replays, the timestamp prevents stale messages and guards against clock issues, and the HMAC ensures the message integrity and authenticity. The server would first validate the HMAC, then check the timestamp, and finally check the nonce against its cache.
The next problem you’ll likely encounter is managing the shared secret keys securely across distributed systems.