How to Generate Cryptographically Secure Idempotency Keys

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.

Idempotency key generation and atomic binding flow Flow diagram showing five stages: CSPRNG entropy source, 16-byte draw, Base64URL encode, atomic SET NX in store, and payload hash binding. OS CSPRNG /dev/urandom BCryptGenRandom Draw 16 bytes 128-bit entropy (256 for fintech) Base64URL encode, strip = padding SET key NX EX Redis / DynamoDB atomic write-once SHA-256 payload hash bound to key 22-char URL-safe string write-once guarantee

Prerequisites

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:

  1. idempotency_key_status — label with new | cached | expired | collision; emit on every deduplication check. Sudden spike in collision indicates a client-side key-reuse bug or entropy regression.
  2. 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.
  3. idempotency_header_missing_count — counter on ingress. Alert at any non-zero value in production; this indicates a misconfigured proxy or client library.
  4. stuck_pending_key_count — gauge representing keys in pending state older than 300 s. Alert threshold: > 0.
  5. idempotency_key span 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.
  6. 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.