Skip to content
Nonce and replay

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

UseMemoryCacheUseDistributedCacheUseDistributedCache with HmacManager.StackExchangeRedis
Backed byin-process memoryany IDistributedCacheRedis
Shared between instancesnoyesyes
Atomic check and recordyesnoyes
Deploymenta single instancemultiple instances, with the concurrency limitation abovemultiple 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.