Part of: Idempotency Key Generation Strategies
This runbook walks through generating keys that are backed by OS-level entropy, encoded for safe transport, and bound atomically to a deduplication store. Before starting, make sure you understand the broader idempotency guarantees and API contracts these keys enforce — in particular, the state machine that transitions a key from pending to completed or failed. Idempotency keys are only meaningful on non-safe HTTP methods (POST, PATCH, DELETE); as explained in why GET and HEAD are inherently idempotent, safe methods must never consume them.
Why Cryptographic Randomness Is Non-Negotiable
Standard PRNGs — Math.random() in JavaScript, rand() in C, random.random() in Python — are deterministic once the seed is known. An attacker who observes a sequence of your keys can predict the next one, manufacture a well-timed replay request with that predicted key, and force your deduplication store to treat the fraudulent request as an already-seen duplicate. The result is either silent suppression of a legitimate payment or forced re-execution of a transaction the attacker controls.
OS-backed cryptographically secure pseudorandom number generators (CSPRNGs) draw from a kernel entropy pool fed by hardware events (interrupt timing, CPU jitter, RDRAND). They are computationally infeasible to invert or predict.
Birthday-paradox collision floor. For a key space of N = 2¹²⁸ and n = 10¹⁵ total generated keys (a quadrillion requests), the collision probability is approximately P ≈ 1 − e^(−n²/2N) ≈ 10⁻¹⁰. At 10⁹ key generations per second it would take over 10⁹ years to reach a 50 % collision probability. 128 bits is the minimum. Payment processors and regulated fintech services should default to 256 bits for regulatory future-proofing.
Prerequisites
- Familiarity with idempotency key generation strategies, specifically the difference between random keys (UUIDv4, raw CSPRNG bytes) and deterministic keys (HMAC-derived).
- A Redis 6.2+ instance or DynamoDB table available to the service generating keys.
- Understanding of atomic key registration via Redis SET NX so you know what “single-writer semantics” means in practice.
Step-by-Step Implementation
Step 1 — Choose the Right Entropy Source
Select the OS-backed CSPRNG for your runtime. Never substitute Math.random(), random.random(), or any time-seeded generator.
| Runtime | Correct CSPRNG | Wrong alternative |
|---|---|---|
| Python 3.6+ | secrets.token_bytes(16) |
random.getrandbits() |
| Go | crypto/rand.Read |
math/rand.Read |
| Node.js | crypto.randomBytes(16) |
Math.random() |
| Java | SecureRandom.getInstanceStrong() |
new Random() |
All of these ultimately call /dev/urandom (Linux), getrandom(2) (Linux 3.17+), or BCryptGenRandom (Windows). On AWS Nitro and GCP hardware the kernel entropy pool is supplemented by an on-chip hardware RNG, so entropy exhaustion is not a practical concern — but you should still validate your entropy source at service startup and fail fast if the runtime cannot guarantee it.
Step 2 — Generate and Encode
Draw 16 bytes (128 bits) from the CSPRNG and encode with Base64URL. Strip the trailing = padding; the result is a 22-character string that is safe in HTTP headers and URL paths without further escaping.
Python
import secrets
import hashlib
def generate_idempotency_key() -> str:
# 16 bytes = 128 bits of entropy; use 32 bytes for payment processor compliance
return secrets.token_urlsafe(16) # returns Base64URL-encoded string, no padding
def payload_hash(body: bytes) -> str:
return hashlib.sha256(body).hexdigest()
Go
package idempotency
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// GenerateKey returns a 22-character Base64URL key with 128 bits of entropy.
func GenerateKey() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("csprng unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// PayloadHash returns the hex-encoded SHA-256 of the request body.
func PayloadHash(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
Node.js
import crypto from 'crypto';
// generateKey returns a 22-char Base64URL string (128-bit entropy).
export function generateKey() {
return crypto.randomBytes(16).toString('base64url');
}
export function payloadHash(body) {
return crypto.createHash('sha256').update(body).digest('hex');
}
Java
import java.security.SecureRandom;
import java.security.MessageDigest;
import java.util.Base64;
public final class IdempotencyKey {
// Use getInstanceStrong() to guarantee OS-backed entropy (may block briefly on first call).
private static final SecureRandom RNG = new SecureRandom();
public static String generate() {
byte[] bytes = new byte[16]; // 128 bits
RNG.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static String payloadHash(byte[] body) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(body);
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
}
}
Step 3 — Bind the Key Atomically in the Deduplication Store
Writing the key must be a single atomic operation. Use Redis SET key value NX EX ttl or a DynamoDB conditional PutItem with attribute_not_exists(idempotency_key). Both reject the write if the key already exists, so exactly-once semantics are preserved without a separate read-then-write check-and-set loop. For detailed trade-offs between these two backends, see Redis cache-based deduplication.
Node.js + Redis (ioredis)
import Redis from 'ioredis';
import { generateKey, payloadHash } from './idempotency.js';
const redis = new Redis();
/**
* Binds an idempotency key atomically.
* Returns true if the key was newly registered; false if it already exists (duplicate request).
*/
export async function bindKey(key, requestBody) {
const hash = payloadHash(Buffer.from(requestBody));
const value = JSON.stringify({ status: 'pending', payloadHash: hash, ts: Date.now() });
// SET key value NX EX 86400 — atomic, single round-trip, 24-hour TTL
const result = await redis.set(key, value, 'EX', 86400, 'NX');
return result === 'OK'; // null means the key already existed
}
/**
* Transition a bound key to completed, storing the response for safe replay.
*/
export async function completeKey(key, responsePayload) {
const existing = JSON.parse(await redis.get(key));
if (!existing) throw new Error('Key not found — possible TTL expiry mid-transaction');
const updated = { ...existing, status: 'completed', response: responsePayload };
// Overwrite with no NX — the key already exists and we are updating its state
await redis.set(key, JSON.stringify(updated), 'EX', 86400);
}
Go + DynamoDB
package idempotency
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
// BindKey writes a new idempotency record; returns (false, nil) if key already exists.
func BindKey(ctx context.Context, db *dynamodb.Client, table, key, payloadHash string) (bool, error) {
ttl := time.Now().Add(24 * time.Hour).Unix()
_, err := db.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(table),
Item: map[string]types.AttributeValue{
"idempotency_key": &types.AttributeValueMemberS{Value: key},
"status": &types.AttributeValueMemberS{Value: "pending"},
"payload_hash": &types.AttributeValueMemberS{Value: payloadHash},
"ttl": &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", ttl)},
},
// Reject the write if the key already exists — no separate read needed
ConditionExpression: aws.String("attribute_not_exists(idempotency_key)"),
})
if err != nil {
var cce *types.ConditionalCheckFailedException
if errors.As(err, &cce) {
return false, nil // duplicate request
}
return false, fmt.Errorf("dynamodb PutItem: %w", err)
}
return true, nil
}
Step 4 — Bind a Payload Hash and Reject Mismatches
Store a SHA-256 digest of the request body alongside the key status. On every retry, recompute the digest and compare it with the stored value. If they differ, the client is reusing an idempotency key across logically distinct requests — reject with 409 Conflict. This is the primary defence against key-reuse bugs in client code and a hard requirement before implementing exponential backoff with jitter, since backoff loops amplify key-reuse bugs by replaying mismatched payloads at scale.
import json
import hashlib
import redis
r = redis.Redis()
def handle_request(key: str, body: bytes, process_fn):
incoming_hash = hashlib.sha256(body).hexdigest()
raw = r.get(key)
if raw is None:
# First time seeing this key — bind it atomically
record = {"status": "pending", "payload_hash": incoming_hash}
set_result = r.set(key, json.dumps(record), ex=86400, nx=True)
if not set_result:
# Lost a race — another worker bound the key between our GET and SET
raw = r.get(key)
record = json.loads(raw)
else:
result = process_fn(body)
record["status"] = "completed"
record["response"] = result
r.set(key, json.dumps(record), ex=86400)
return result, 200
record = json.loads(raw)
if record["payload_hash"] != incoming_hash:
# Key reuse across different payloads — hard reject
return {"error": "idempotency key already used with a different payload"}, 409
if record["status"] == "completed":
return record["response"], 200 # safe replay
# status == "pending" — another worker is processing; return 202 or wait
return {"status": "processing"}, 202
Step 5 — Normalize Key Format at the Ingress Layer
Validate and normalize every inbound Idempotency-Key header before it reaches your application logic. Reject keys that do not match the Base64URL alphabet or fall outside the 16–43 character range (16 chars = 96-bit, 22 chars = 128-bit, 43 chars = 256-bit encoded). Normalizing before storage prevents encoding mismatches where abc+def and abc-def refer to the same bytes but differ as string keys.
import re
# Matches unpadded Base64URL (16–43 chars covers 96-bit to 256-bit keys)
_KEY_RE = re.compile(r'^[A-Za-z0-9_-]{16,43}$')
def validate_key(key: str) -> bool:
return bool(_KEY_RE.match(key))
Verification & Testing
Run these checks before declaring the implementation production-ready.
1. Confirm the CSPRNG source. On Linux, verify your process can read from the kernel entropy pool:
python3 -c "import secrets; print(secrets.token_urlsafe(16))"
# Should print a 22-char Base64URL string without blocking
2. Simulate a clean first request. Send a POST with a fresh key and body. Expect HTTP 200 and the normal response. Inspect Redis to verify the key is stored with status: completed and a payload_hash value.
KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))")
curl -s -X POST https://api.example.com/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 5000, "currency": "usd"}' | jq .
redis-cli GET "$KEY" | jq '{status: .status, hash: .payload_hash}'
3. Simulate a safe retry. Replay the identical request with the same key and body. Expect HTTP 200 with the same response body as step 2 — no duplicate processing.
curl -s -X POST https://api.example.com/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 5000, "currency": "usd"}' | jq .
# Must return the same response as step 2
4. Simulate a key-reuse conflict. Replay with the same key but a different body. Expect HTTP 409 Conflict.
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://api.example.com/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 9999, "currency": "usd"}'
# Must print 409
5. Confirm TTL expiry. Set a short TTL in a test environment (e.g., 10 seconds), wait for expiry, and confirm a new request with the same key creates a fresh record rather than returning a stale 409.
Failure Scenarios & Debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Key collision causes false 409 Conflict on valid retry |
Compare incoming payload hash against stored hash before rejecting; allow identical payloads to replay through | idempotency_collision_rate_percent (Gauge); alert at > 0.1 % over 5-minute windows |
Partial write leaves key in pending indefinitely after worker crash |
Run a background sweeper that expires pending keys older than the max transaction timeout (300 s is a safe default); use a Redis HSCAN or DynamoDB GSI on status |
stuck_pending_keys (Dashboard with TTL-breach alerts) |
| Client reuses key across different payloads | Enforce strict key-to-payload binding at ingress; reject mismatched retries with 409 Conflict and log both hashes |
payload_hash_mismatch_count (Counter); log idempotency_key, stored_hash, incoming_hash as structured fields |
Gateway strips Idempotency-Key header before it reaches the origin |
Add header-preservation middleware at ingress; emit a counter when the header is absent on a POST/PATCH/DELETE |
idempotency_header_missing_count (Counter) |
| TTL expires mid-transaction, allowing a second worker to bind the same key | Use dynamic TTL extension: heartbeat-ping the key during long-running operations (EXPIRE key 86400 every 60 s) |
idempotency_ttl_extended (Log with transaction_id correlation) |
SRE / Observability Checklist
Instrument these six signals before promoting the service to production:
idempotency_key_status— label withnew | cached | expired | collision; emit on every deduplication check. Sudden spike incollisionindicates a client-side key-reuse bug or entropy regression.idempotency_dedup_lookup_latency_ms— histogram, p50/p95/p99. Deduplication lookups must stay under 5 ms p99; breaches trigger circuit breakers that fall back to synchronous processing.idempotency_header_missing_count— counter on ingress. Alert at any non-zero value in production; this indicates a misconfigured proxy or client library.stuck_pending_key_count— gauge representing keys inpendingstate older than 300 s. Alert threshold: > 0.idempotency_keyspan attribute — attach as an OpenTelemetry span attribute on every inbound request that carries the header. This binds distributed traces across retries and makes replay debugging possible in Jaeger/Tempo without cross-referencing logs manually.payload_hash_mismatch_count— counter; alert immediately at any non-zero value. This signal is almost always a client bug, not a transient failure, and should page the on-call engineer.
Export all spans with W3C Trace Context headers propagated to async workers. Scrub the idempotency_key value from PII-sensitive logs if your keys embed user identifiers, but retain the SHA-256 payload hash — it is safe to log and essential for replay audits.
Related
- Idempotency Key Generation Strategies — parent page covering UUIDv7, ULID, and HMAC-deterministic approaches alongside this CSPRNG runbook.
- Using Redis SET NX for distributed request deduplication — deep dive into the atomic binding pattern used in Step 3.
- Implementing exponential backoff without overlapping retries — the retry strategy that consumes the keys generated here.