Skip to main content

Choosing a Backend for Operational State

Everything Abblix OIDC Server keeps between requests (authorization grants, pushed authorization requests, CIBA and device-flow records, token revocation marks, replay-prevention markers) lives as expiring entries in whatever IDistributedCache implementation the host registers. The library deliberately registers none of its own: there is no default and no fallback, and the first protocol request fails dependency resolution if the host forgot to supply one. AddDistributedMemoryCache() gets a sample running; this guide is about choosing what runs in production, because the choice carries one functional requirement and one security requirement that are easy to miss.

What actually lives in the cache

Each entry kind arrives with its own TTL, driven by the matching option or token lifetime, which is why this store never needs a cleanup job; it expires everything itself:

  • Authorization grants: the state behind an issued authorization code, kept for the code's lifetime (per-client, 1 minute by default).
  • Pushed authorization requests (PAR): consumed atomically at the authorize endpoint.
  • Token status marks: the Revoked/Used verdicts written by the revocation endpoint, refresh-token rotation, authorization-code reuse detection, and one-time client assertions; each mark lives exactly as long as the token it condemns.
  • CIBA and device-flow requests: polled state, plus the device flow's user-code map and its brute-force rate-limit counters.
  • Replay-prevention markers: used jti marks for DPoP proofs and JWT-bearer assertions, and the rolling HMAC secrets behind DPoP nonces.

Entries are serialized with protobuf (a JSON fallback covers host-supplied types the protobuf whitelist does not know). Encrypting them at rest is its own topic with its own pitfalls; the Production Hardening Checklist owns it.

Requirement one: shared, or multi-instance breaks

The protocol itself hops between requests, and under a load balancer those requests land on different instances. With a per-instance cache (a plain memory cache on each pod), every one of these flows breaks:

  • Authorization code exchange: the code is minted on the node that served /authorize and redeemed on whichever node serves /token; if the grant is not in a shared store, redemption returns invalid_grant. Intermittently, depending on the load balancer: the worst kind of failure to diagnose.
  • PAR: the request_uri is pushed to one node and consumed on another.
  • CIBA and device flow: the client polls the token endpoint repeatedly, and each poll may hit a different instance; the user approves on yet another.
  • DPoP nonces: the rolling HMAC secret must be readable by every instance, or nonces issued by one node fail validation on the next and force clients into retry loops.
  • Rate limiting: per-instance counters multiply an attacker's allowance by the pod count.

So the first requirement is simply shared. This is also why the in-memory cache is a development tool, not a deployment option, even before durability enters the picture.

Requirement two: persistent, or a wipe un-revokes tokens

The less obvious requirement is durability, and it comes from the token status marks. Revocation in Abblix's stateless model works negatively: tokens carry their own validity, and the cache holds only the marks that condemn them, checked on every validation of a token that carries a jti. Wipe the cache, and the marks are gone:

  • a revoked refresh or access token validates again, until its own natural expiry;
  • a rotated-out refresh token or an already-used one-time client assertion is accepted again;
  • used jti replay marks vanish, reopening the replay window for DPoP proofs and JWT-bearer assertions within their validity period.

Nothing in the deployment fails visibly; that is what makes it a security requirement rather than an operational one. Treat the cache as a security store and run it with persistence enabled, on encrypted disk, never as an ephemeral instance that is free to restart empty. For a store holding revocation marks prefer AOF (appendfsync everysec or stronger): RDB snapshots alone leave a minutes-wide window in which a crash silently un-revokes everything marked since the last snapshot. The Production Hardening Checklist makes the same point from the defense side; this is the storage decision that implements it.

Latency sits on the token path

The backend's round-trip time is not background noise; it lands directly in request latency:

  • Every validation of a jti-bearing token does one cache read for the status mark: introspection, userinfo, every flow that validates a token server-side.
  • Every DPoP-protected request adds a get-and-set pair for the replay mark, plus a nonce-secret read where DPoP nonces are enabled.
  • Consuming a PAR request, a CIBA poll, or a device-code exchange runs the atomic get-and-remove protocol: five sequential cache round-trips, the price of guaranteeing exactly one consumer on top of the plain IDistributedCache contract. On a 1 ms backend that is invisible; on a 20 ms cross-zone hop it is a hundred milliseconds added to the token endpoint.

Keep the cache topologically close to the server instances, and prefer backends where a round trip is measured in fractions of a millisecond.

The candidates

  • Redis is the canonical choice and what Abblix's own identity provider runs: shared, sub-millisecond, persistence a configuration flag, and available on every cloud. If you take one recommendation from this page, it is a private, persistent, TLS-protected Redis close to the pods.
  • SQL Server distributed cache (or an equivalent relational-backed IDistributedCache) inverts the trade: durable by nature and already inside many enterprises' operational comfort zone, at the cost of higher per-read latency (workable for modest traffic, measurable at scale).
  • In-memory (AddDistributedMemoryCache): development and single-instance evaluation only; it fails both requirements above by construction.
  • Anything else implementing IDistributedCache works unmodified; the library never sees past the interface. Judge a candidate by the two requirements and the latency profile, not by its name.

One honesty note for the strictest deployments: IDistributedCache offers no atomic compare-and-set, so the jti replay mark is written get-then-set: a probabilistic defense under extreme concurrent replay, as the source itself documents. A host that needs a hard guarantee can replace the replay cache seam with a backend-native atomic (Redis SET NX EX); one more interface, same pattern as every other seam in this cookbook.

Verify the deployment, not the config

Two experiments prove the two requirements. Run them in staging with production-identical persistence configuration, never against live traffic: restarting the cache drops all operational state, and if persistence turns out to be off (the very thing the second experiment tests), the restart un-revokes every previously revoked token system-wide, not just your test one:

  • Shared: run two instances behind the balancer, complete an authorization-code flow with sticky sessions disabled, and repeat until both nodes have served each leg; no invalid_grant may appear.
  • Persistent: revoke a refresh token through the revocation endpoint, restart the cache backend, and present the revoked token again; it must still be rejected. If it validates, persistence is not actually on.

Where this sits in the bigger picture

This page covers operational state, the short-lived entries the server itself expires. Clients are a different store with different durability rules (A Durable Client Store), and a queryable inventory of issued tokens is a build-out on separate seams (Token Inventory and Incident Revocation).