Skip to content

How-to: Idempotency-Key Pattern

FT reference: FT276 (NENE2-FT/csrflog) — Idempotency-Key header for state-changing requests: UNIQUE DB constraint, replay returns original result (200), body changes on replay are ignored, race condition handled by DatabaseConstraintException, 15 tests / 30 assertions PASS.

ATK assessment: ATK-01 through ATK-12 included at the end of this document.

Prevent duplicate orders or resource creation caused by network retries by requiring clients to supply an Idempotency-Key header on every state-changing request.

Why it matters

When a client sends POST /orders and the network drops before it receives a response, it will retry. Without idempotency, that retry creates a second order. With an Idempotency-Key, the server can detect the retry and return the original result instead of creating a duplicate.

Stripe, GitHub, and many other production APIs use this exact pattern.

Database schema

Add a UNIQUE constraint on the idempotency key column. This single constraint handles the race condition described below.

sql
CREATE TABLE orders (
    id               INTEGER PRIMARY KEY AUTOINCREMENT,
    idempotency_key  TEXT    NOT NULL UNIQUE,
    item             TEXT    NOT NULL,
    quantity         INTEGER NOT NULL,
    total_price      REAL    NOT NULL,
    created_at       TEXT    NOT NULL
);

Handler implementation

php
// 1. Read and validate the header
$key = trim($request->getHeaderLine('Idempotency-Key'));
if ($key === '') {
    return $problems->create(
        $request,
        'missing-idempotency-key',
        'Idempotency-Key header is required for this endpoint.',
        [],
        422,
    );
}

// 2. Check for an existing entry (replay path)
$existing = $repo->findByIdempotencyKey($key);
if ($existing !== null) {
    return $json->create($existing->toArray(), 200); // replay — return original with 200
}

// 3. Validate the request body
$body = json_decode((string) $request->getBody(), true);
// ... validate fields ...

// 4. Create — UNIQUE constraint handles the race condition
try {
    $order = $repo->create($key, $item, $quantity, $totalPrice);
    return $json->create($order->toArray(), 201);
} catch (DatabaseConstraintException) {
    // Another request with the same key won the race — return its result
    $existing = $repo->findByIdempotencyKey($key);
    if ($existing !== null) {
        return $json->create($existing->toArray(), 200);
    }
    return $problems->create($request, 'conflict', 'Conflict.', [], 409);
}

Repository

php
public function findByIdempotencyKey(string $key): ?Order
{
    $row = $this->executor->fetchOne(
        'SELECT * FROM orders WHERE idempotency_key = ?',
        [$key],
    );
    return $row !== null ? Order::fromRow($row) : null;
}

public function create(string $key, string $item, int $quantity, float $totalPrice): Order
{
    // Throws DatabaseConstraintException on UNIQUE violation (race condition)
    $this->executor->insert(
        'INSERT INTO orders (idempotency_key, item, quantity, total_price, created_at) VALUES (?, ?, ?, ?, ?)',
        [$key, $item, $quantity, $totalPrice, (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
    );
    // ...
}

Key design decisions

Replay returns 200, not 201

The second request is a replay, not a creation. Using 200 OK tells the client "you've seen this before" without creating confusion about what was created.

Replay ignores the body

If the client sends the same Idempotency-Key with a different body, the original result is returned. The server treats a matching key as proof that the request was already processed, regardless of what the body says.

POST /orders  Idempotency-Key: uuid-abc  body: {quantity: 1, price: 9.99}
→ 201 Created  {id: 1, quantity: 1}

POST /orders  Idempotency-Key: uuid-abc  body: {quantity: 99, price: 0.01}
→ 200 OK  {id: 1, quantity: 1}   ← original order, body ignored

This is intentional. If the client wants to create a genuinely different resource, it must use a new key.

UNIQUE constraint as the race condition guard

Two concurrent requests with the same key will race. The DB's UNIQUE constraint ensures only one insert succeeds. The loser catches DatabaseConstraintException and fetches the winner's row.

What clients should use as the key

UUID v4 is the most common choice. The client generates the key before sending the request and stores it locally so it can retry with the same key if needed.

js
// Client-side (JavaScript)
const key = crypto.randomUUID();
const response = await fetch('/orders', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
    },
    body: JSON.stringify({ item: 'Widget', quantity: 1, price: 9.99 }),
});

Reading the header

PSR-7 header names are case-insensitive. getHeaderLine('Idempotency-Key'), getHeaderLine('idempotency-key'), and getHeaderLine('IDEMPOTENCY-KEY') all return the same value. NENE2 uses Nyholm/PSR-7 which implements this correctly.


ATK Assessment — Cracker-Mindset Attack Test

ATK-01 — Omit Idempotency-Key to bypass duplicate check 🚫 BLOCKED

Attack: Send POST /orders without the Idempotency-Key header. Result: BLOCKED — trim($request->getHeaderLine('Idempotency-Key')) === '' → 422 with missing-idempotency-key problem detail. No order is created.


ATK-02 — Send empty Idempotency-Key 🚫 BLOCKED

Attack: Send Idempotency-Key: (whitespace only). Result: BLOCKED — trim() reduces whitespace-only strings to '' → same 422 as ATK-01.


ATK-03 — Replay with modified body to change order content 🚫 BLOCKED

Attack: Send POST /orders with key uuid-abc and {quantity: 1}. On replay, use the same key with {quantity: 99}. Result: BLOCKED — the server finds the existing row by idempotency_key and returns it immediately, before reading the body. The new body is never processed.


ATK-04 — Create two orders with different keys 🚫 BLOCKED (intended)

Attack: Use two different Idempotency-Key values to legitimately create two orders. Result: SAFE (by design) — different keys are different requests. Both orders are created. This is the intended behavior: idempotency is per-key, not per-body.


ATK-05 — Race condition: two concurrent requests with the same key 🚫 BLOCKED

Attack: Send two identical requests concurrently before either completes. Result: BLOCKED — both requests pass the findByIdempotencyKey check (no existing row yet), but only one INSERT succeeds. The loser catches DatabaseConstraintException, fetches the winner's row, and returns it with 200. The UNIQUE constraint is the race guard.


ATK-06 — Negative quantity injection 🚫 BLOCKED

Attack: Send {item: "widget", quantity: -1, price: 9.99} with a valid key. Result: BLOCKED — if ($quantity <= 0) → 422 validation error. No order is created.


ATK-07 — Zero quantity injection 🚫 BLOCKED

Attack: Send {item: "widget", quantity: 0, price: 9.99}. Result: BLOCKED — same quantity <= 0 guard → 422.


ATK-08 — Missing required body fields 🚫 BLOCKED

Attack: Send {quantity: 1} without item field. Result: BLOCKED — if ($item === '') → 422 validation error.


ATK-09 — CSRF via cross-origin browser request 🚫 BLOCKED (design)

Attack: Malicious website makes a cross-origin POST /orders request from a browser. Result: BLOCKED (by design) — JSON APIs require Content-Type: application/json. Browser CSRF attacks can only send form-encoded or plain-text bodies via <form> without a preflight. A JSON body triggers a CORS preflight; the server's CORS policy determines whether cross-origin writes are allowed. Additionally, requiring Idempotency-Key provides secondary protection since forged requests cannot predict a unique key.


ATK-10 — Negative price injection 🚫 BLOCKED

Attack: Send {item: "widget", quantity: 1, price: -100.0}. Result: BLOCKED — if ($price < 0) → 422 validation error.


ATK-11 — Float/string quantity coercion 🚫 BLOCKED

Attack: Send {quantity: "1"} or {quantity: 1.5} (string or float). Result: BLOCKED — is_int($body['quantity']) rejects strings and floats; 1.5 is float → 422.


ATK-12 — SQL injection via Idempotency-Key 🚫 BLOCKED

Attack: Send Idempotency-Key: '; DROP TABLE orders; --. Result: BLOCKED — the key is used only in parameterized queries (WHERE idempotency_key = ?). SQL injection via header value is not possible.


ATK Summary

IDAttackResult
ATK-01Missing Idempotency-Key🚫 BLOCKED
ATK-02Empty/whitespace-only key🚫 BLOCKED
ATK-03Replay with modified body🚫 BLOCKED
ATK-04Different keys = different orders✅ SAFE (intended)
ATK-05Race condition on same key🚫 BLOCKED
ATK-06Negative quantity🚫 BLOCKED
ATK-07Zero quantity🚫 BLOCKED
ATK-08Missing body fields🚫 BLOCKED
ATK-09CSRF via cross-origin POST🚫 BLOCKED
ATK-10Negative price🚫 BLOCKED
ATK-11Float/string quantity coercion🚫 BLOCKED
ATK-12SQL injection via key header🚫 BLOCKED

12 BLOCKED / SAFE, 0 EXPOSED The Idempotency-Key pattern, parameterized queries, and strict is_int() validation prevent all tested attack vectors.

Released under the MIT License.