Skip to main content

guides/replay_nonce_production.md

# DPoP replay and nonce stores in production

The default DPoP `jti` replay cache and DPoP nonce store are **single-node,
in-memory (ETS / process-local) stores**. They are for development and test
only. Do not run them in a multi-node deployment.

## Why the in-memory stores are dev/test only

DPoP (RFC 9449) defends against proof replay by remembering every `jti` it has
seen within the proof's acceptance window and rejecting a second use (RFC 9449
§11.1). Server-issued DPoP nonces (RFC 9449 §8 / §9) work the same way: the
nonce a client must echo is tracked server-side.

An in-memory store remembers only what *one* node has seen. With two or more
nodes behind a load balancer, a replayed proof that lands on a different node
than the original is **not** detected, because that node never saw the first
use. The replay protection silently degrades to "per-node," which is no
protection at all under any normal load-balancing.

> DPoP replay protection is only as strong as the shared store behind it. If
> the store is not shared across every node that terminates token requests, the
> protection is not real.

## What production requires

Wire a shared store that every node reads and writes:

  * **Replay check** - set `:replay_check` to a shared implementation. The
    library ships `AttestoPhoenix.Store.EctoReplayCheck`, backed by the same
    Ecto repo as the rest of the token stores:

        replay_check: &AttestoPhoenix.Store.EctoReplayCheck.check_and_record/2

  * **Nonce store** - set `:nonce_store` to a shared
    `Attesto.DPoP.NonceStore` implementation. The library ships
    `AttestoPhoenix.Store.EctoNonceStore`:

        nonce_store: AttestoPhoenix.Store.EctoNonceStore

A Redis-backed store is equally valid as long as every node shares it; the
contract is "one store, all nodes."

## TTL and the sweeper

A shared replay/nonce store accumulates rows that are only relevant for the
proof acceptance window. Two things keep it bounded:

  * **TTL** - each recorded `jti` / nonce carries an expiry tied to the
    acceptance window. An expired row can never cause a false replay rejection,
    so it is safe to delete.

  * **Sweeper** - `AttestoPhoenix.Store.Sweeper` periodically deletes expired
    rows. Start it under your supervision tree and set the interval via
    `:sweep_interval_ms` in `AttestoPhoenix.Config`:

        sweep_interval_ms: 60_000

    If `:sweep_interval_ms` is unset the sweeper is not started, and expired
    rows are retained until you prune them another way.

## Checklist

  - [ ] `:replay_check` points at a store shared by every node.
  - [ ] `:nonce_store` points at a store shared by every node.
  - [ ] The store's tables are migrated (`mix attesto_phoenix.gen.migration`).
  - [ ] `AttestoPhoenix.Store.Sweeper` is supervised with `:sweep_interval_ms`
        set, or another prune mechanism is in place.