In-memory, TTL-bounded cache of seen DPoP proof jti values.
RFC 9449 §11.1 requires the resource server to refuse a DPoP proof
whose jti it has previously processed. A captured-and-replayed proof
would otherwise be reusable for the full iat acceptance window
(default 60 seconds).
This module is a ready-made implementation for the :replay_check
option of Attesto.DPoP.verify_proof/2. It stores jti values in a
public ETS table owned by a GenServer that sweeps expired entries on
a fixed interval; lookups are O(1) and lock-free via
:ets.insert_new/2.
Single-node deployment invariant (load-bearing)
This implementation is a per-node ETS singleton. RFC 9449 §11.1 replay
rejection only holds across the deployment if every request for a
given access token reaches the same node - otherwise a captured proof
is replayable once per node behind a load balancer. On a multi-node
deployment you MUST swap the verifier's :replay_check callback for a
shared-store implementation (e.g. a Postgres-backed cache using
INSERT ... ON CONFLICT DO NOTHING for an atomic record-and-check, or
Redis) and set :multi_node_acknowledged?: true to silence the
boot-time guard. The verifier's :replay_check shape
((jti, ttl_seconds) -> :ok | {:error, :replay}) lets any such
replacement plug in without changes to Attesto.DPoP. The verifier
passes its own :max_age_seconds as ttl_seconds, so a shared store
can size each jti's retention to the proof's freshness window.
The boot-time guard raises on startup if Node.list/0 is non-empty
and :multi_node_acknowledged? is not set - a clustered BEAM with a
node-local replay cache is a silently-broken security boundary (a
captured proof becomes replayable once per node) that this guard
refuses to enter. Failing the supervised start surfaces the
misconfiguration loudly rather than emitting a log nobody reads.
Configuration (start options)
:ttl_seconds(default60) - how long eachjtiis remembered. SHOULD match (or modestly exceed) the verifier's:max_age_secondsso a proof whoseiatwindow has already closed is rejected by freshness OR by replay, never just by eviction race.:sweep_interval_ms(default30_000) - how often expired entries are deleted in bulk. The cache is correct without sweeping (lookups re-validate expiry); the sweeper just bounds table size.:multi_node_acknowledged?(defaultfalse) - set totrueafter wiring a shared-store:replay_checkso the boot-time guard does not fire on a clustered BEAM.
Wiring
children = [
{Attesto.DPoP.ReplayCache, ttl_seconds: 60}
]then, at the verifier:
Attesto.DPoP.verify_proof(proof,
http_method: "GET",
http_uri: uri,
replay_check: &Attesto.DPoP.ReplayCache.check_and_record/2
)
Summary
Functions
Record jti and report whether it has already been seen within the TTL
window.
Clear every entry from the cache. Test-facing.
Return the number of entries currently held. Test/diagnostic-facing.
Start the cache. Registered under __MODULE__.
Functions
@spec check_and_record(String.t(), pos_integer()) :: :ok | {:error, :replay}
Record jti and report whether it has already been seen within the TTL
window.
Returns :ok if the jti was not present (and has now been recorded),
or {:error, :replay} if it was. The two-argument form
(check_and_record/2) takes the jti and the TTL to remember it for,
which is the shape Attesto.DPoP.verify_proof/2 passes its
:replay_check callback (the verifier derives the TTL from its own
acceptance window). Pass &check_and_record/2 directly. The TTL
argument defaults to 60 seconds when called as
check_and_record/1.
@spec reset() :: :ok
Clear every entry from the cache. Test-facing.
@spec size() :: non_neg_integer()
Return the number of entries currently held. Test/diagnostic-facing.
@spec start_link(keyword()) :: GenServer.on_start()
Start the cache. Registered under __MODULE__.