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_checkto a shared implementation. The library shipsAttestoPhoenix.Store.EctoReplayCheck, backed by the same Ecto repo as the rest of the token stores:replay_check: &AttestoPhoenix.Store.EctoReplayCheck.check_and_record/2Nonce store - set
:nonce_storeto a sharedAttesto.DPoP.NonceStoreimplementation. The library shipsAttestoPhoenix.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.Sweeperperiodically deletes expired rows. Start it under your supervision tree and set the interval via:sweep_interval_msinAttestoPhoenix.Config:sweep_interval_ms: 60_000If
:sweep_interval_msis unset the sweeper is not started, and expired rows are retained until you prune them another way.
Checklist
- [ ]
:replay_checkpoints at a store shared by every node. - [ ]
:nonce_storepoints at a store shared by every node. - [ ] The store's tables are migrated (
mix attesto_phoenix.gen.migration). - [ ]
AttestoPhoenix.Store.Sweeperis supervised with:sweep_interval_msset, or another prune mechanism is in place.