Nonce and replay
A valid signature stays valid. Without something else in play, anyone who captures a signed request can send it again and it will verify — the signature is still correct, because nothing about it has changed.
Two things prevent that, and they work together:
The timestamp. Every signature covers Hmac-DateRequested. The verifier
rejects anything older than the policy’s maxAgeInSeconds, which bounds how
long a captured request is useful at all.
The nonce. Every signature covers a Guid generated per request. The
verifier stores each nonce it accepts for the length of that same window and
rejects a repeat. So inside the window a request works exactly once, and
outside it the timestamp check has already rejected it.
Neither can be tampered with: both are in the signing content, so changing either invalidates the signature.
Verification order
The verifier checks the date window first, then the signature, and only then records the nonce — so a nonce is recorded only for a request whose signature is valid. The order matters: recording a nonce before authenticating the request would let an unauthenticated caller spend a genuine request’s nonce ahead of it, or fill the cache with nonces of its own.
Recording is one operation: the cache is asked to claim the nonce until
dateRequested + maxAgeInSeconds, and answers whether it was unused. A request
can expire while its signature is computed, so the verifier checks the date
again immediately before asking: an expired request never reaches the cache,
and a refusal from it is always a replay.
A claim should be atomic, so two concurrent copies of the same request cannot
both be accepted. The memory cache’s is. The Distributed cache type is built
on IDistributedCache, which has no conditional write, so it stays
check-then-set: two copies of a request arriving at the same moment on
different instances can both pass. In .NET, the
HmacManager.StackExchangeRedis package
takes its place with an atomic claim in Redis, and another store with an atomic
primitive can do the same as a custom nonce cache.
Choosing the window
policy.UseMemoryCache(maxAgeInSeconds: 300);maxAgeInSeconds is both the freshness window and how long a nonce is
remembered. It has to absorb the real clock skew between signer and verifier
plus network time — too tight and legitimate requests fail; too loose and a
captured request stays replayable for longer, and the cache holds more.
Minutes, not hours. The chart defaults to 60 seconds.
Which cache
UseMemoryCache | UseDistributedCache | UseDistributedCache with HmacManager.StackExchangeRedis | |
|---|---|---|---|
| Backed by | in-process memory | any IDistributedCache | Redis |
| Shared between instances | no | yes | yes |
| Atomic check and record | yes | no | yes |
| Deployment | a single instance | multiple instances, with the concurrency limitation above | multiple instances |
The in-memory cache is per process. Behind a load balancer with two instances, a replayed request that lands on the instance that has not seen the nonce is accepted — so the protection quietly does nothing. Any multi-instance deployment needs the distributed cache.
policy.UseDistributedCache(maxAgeInSeconds: 300);The distributed cache uses the registered IDistributedCache, so it is wired
the usual ASP.NET Core way:
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = "localhost:6379");If no IDistributedCache is registered, AddHmacManager registers
MemoryDistributedCache, which keeps entries in process memory. A policy on
UseDistributedCache then works, but no instance sees another’s nonces, so
across instances it protects no more than UseMemoryCache. Register a shared
IDistributedCache, or use the
Redis package, for any multi-instance
deployment.
Nothing fails, so the host logs it once at startup, at Warning as event
1203, naming the policies. That includes an AddDistributedMemoryCache you
registered yourself, which is the same in-process store. It is not logged when
AddNonceCache replaced the Distributed cache, or for scoped policies, which
are not known until a request resolves them.
In the TypeScript client
The npm package verifies too, and applies the same two checks with the same
rules, in the same order: date, signature, then the nonce. maxAgeInSeconds
lives on the policy and the nonce store is the NonceStore interface, whose
tryAdd makes the same claim with the policy’s window passed on every call.
The in-process MemoryNonceStore is shipped as the default; a shared store
should implement tryAdd with its own atomic primitive:
new HmacManagerFactory(policies, false, myRedisNonceStore);The in-process default carries exactly the hazard described above, for the same reason. See replay protection.
In Kubernetes
The chart bundles Redis and every policy uses the distributed cache, so
multi-replica is the default and correct configuration. Setting
redis.enabled=false falls back to the in-process cache, and the chart then
refuses replicaCount > 1 rather than letting you deploy something that does
not protect anything. See Redis.