Post

Idempotency Keys Are Security Boundaries, Too

Design retry-safe operations with tenant-scoped keys, payload binding, durable state, and recovery for ambiguous downstream outcomes.

Idempotency Keys Are Security Boundaries, Too

A payment or account-provisioning endpoint can authenticate every request and still perform the same sensitive action twice. Retries, queue redelivery, and process crashes create a security problem when the business invariant is “this authorization permits one effect.”

An idempotency key is useful only when the server defines its scope and lifecycle. A global key can let one tenant collide with another. A key stored without a payload digest can accidentally authorize a different operation on retry.

Model the operation explicitly

For an illustrative API, I would store this record:

1
2
3
4
5
6
7
8
9
CREATE TABLE operations (
  tenant_id uuid NOT NULL,
  operation_kind text NOT NULL,
  idempotency_key text NOT NULL,
  request_digest text NOT NULL,
  state text NOT NULL CHECK (state IN ('pending', 'complete', 'uncertain')),
  result_reference text,
  PRIMARY KEY (tenant_id, operation_kind, idempotency_key)
);

Resolve tenant_id through authenticated membership. Bound the key’s length and format. Compute the digest from a versioned canonical representation of the relevant request fields, including the target and amount where applicable. Arbitrary JSON serialization is not a stable canonicalization contract.

An existing key with a different digest is a conflict. An existing completed operation returns its authorized result reference. A pending operation reports that work is still in progress. Every branch still authorizes the current caller; knowledge of the key must not become access to another user’s result.

The database cannot make an external effect atomic

For an effect entirely inside one database, the operation record and mutation can share a transaction. An external provider introduces a gap: the provider may finish before the local process records completion.

Use a downstream idempotency facility where available, carrying a stable operation identity. Otherwise reconcile the provider’s state before retrying an uncertain operation. A transactional outbox makes local intent durable, but its consumer can still deliver more than once. It does not create exactly-once behavior at an arbitrary external service.

Unique constraints arbitrate concurrent attempts. Applications also need deliberate handling for conflicts and transaction retries; PostgreSQL’s transaction isolation documentation explains why concurrent transactions require more than a read-before-write check.

Review the recovery path before launch

I would walk the team through three crashes: before the provider call, after provider success, and after local completion but before the HTTP response. For each, the runbook must explain how the next attempt determines whether another effect is safe.

Retention is also a product decision. Deleting a key permits later reuse, so the retention window must exceed the supported retry and redelivery horizon. Document what happens after that horizon.

The service owner owns reconciliation, customer support owns the communication path for uncertain outcomes, and security reviews cross-tenant and unauthorized-result cases. A useful readiness metric is the age of unresolved uncertain operations. A low duplicate-response rate is not enough if the system silently accumulates business effects that nobody can explain.

This post is licensed under CC BY 4.0 by the author.