Skip to content

Multi-instance deployment

The OP is stateless across HTTP requests; every replica reads and writes through the configured store.Store. Going from one replica to N shifts the conversation from "what's in process memory" to "what's shared, what's volatile, and what fan-out behaviour is acceptable".

N replicas, one store, one volatile tier
A replica topology. A load balancer spreads one client's requests across several OP replicas, which share a single durable store and a single volatile Redis tier. Because a nonce may be issued by one replica and checked by another, that state has to live outside the process.RP / clientPOST /token · GET /userinfoLoad balancerround-robin, no session affinityOP #1issues a DPoP nonceOP #2holds nothing of its ownOP #3checks that same nonceDurable store — sharedclients · codes · refresh and access tokensgrants · issued-at records · PAR requestsone backend behind every replicaVolatile tier — RedisSessions · InteractionsConsumedJTIs · DPoP noncesevicted on restart or at maxmemory
No replica is special and none of them remember anything, which is what lets the load balancer stay dumb. The price is that every piece of cross-request state has to be somewhere both boxes at the bottom can see.

What's shared automatically

Anything routed to the same external backend from every replica is shared. In a composite store, the seven composite.TxClusterKinds members — authorization codes, refresh tokens, grants, PARs, JWT access-token registrations, opaque access tokens, and grant-revocation tombstones — must use one comparable store.Transactional backend. These substores sit outside that cluster and may be routed separately:

  • clients
  • sessions
  • users
  • IATs / RATs
  • device codes
  • CIBA requests
  • interactions
  • consumed JTIs
  • metadata

Production replicas must still use shared placement wherever cross-replica visibility is required.

Volatile substores eligible for a Redis tier are Sessions, Interactions, and ConsumedJTIs (the JAR / DPoP / private_key_jwt replay set) — see Hot/cold split. The DPoP server-nonce store is a separate seam wired through op.WithDPoPNonceSource, not a substore.

The authn-factor lockout counter wired through op.WithAuthnLockoutStore is not part of the store.Store substore set either, so attach it explicitly. The bundled SQL and DynamoDB adapters provide durable, replica-shared implementations through storage.AuthnLockouts():

go
op.WithAuthnLockoutStore(storage.AuthnLockouts())

inmem.Store.AuthnLockouts() is process-local: it resets on restart and gives each replica its own guess budget. An embedder using another backend must provide a durable, shared store.AuthnLockoutStore when cross-factor brute-force lockout must survive restarts and be consistent across replicas.

Device-code verification has the same distinction between record state and attempt state. devicecodekit.VerifyUserCode and VerifyUserCodeByUserCode use the shared device-code store for record-bound flows. For a first-entry manual page that does not yet know a device_code, use devicecodekit.VerifyUserCodeByAttemptKey with an opaque, stable ceremony key (for example a browser-session or account-scoped key). Supply a shared devicecodekit.AttemptLimiter in devicecodekit.Deps; the default InMemoryAttemptLimiter is per-process and gives each replica a separate budget. A distributed limiter's Allow operation must atomically compare-and-increment; reset the key only from the authenticated ceremony owner.

What needs explicit attention

ConcernSingle replicaN replicas
DPoP server noncein-memory reference source ships with the libraryneeds distributed source
Session cookiesencrypted with WithCookieKeys; shared across replicas as long as the key matchessame — every replica must share the cookie key
Interaction state (/interaction/{uid})typically in-memoryneeds Redis or sticky sessions
Rate limitingupstream / out-of-processupstream / out-of-process
OFCS conformance harnessruns against one OPruns against one OP — point at one replica or the load balancer

DPoP server-nonce store

op.NewInMemoryDPoPNonceSource is single-process. Behind a load balancer that round-robins requests, the nonce a replica issues at /token won't be recognised by the replica that handles the next /userinfo.

Two paths:

  1. Disable the server-nonce flow. Don't pass WithDPoPNonceSource. Clients then proceed without server-supplied nonces. This is the safe default when you don't need RFC 9449 §8 hardening.
  2. Plug a distributed source. Implement op.DPoPNonceSource against a shared store (Redis, Memcached). The library deliberately does not ship a Redis nonce source — the option matrix (TTL, rotation cadence, missed-rotation tolerance) is too specific to operator setup.
go
// Sketch — wrap a Redis-backed implementation behind the seam.
// op.DPoPNonceSource is two methods; the source decides its own
// rotation cadence and validity window.
type redisNonces struct{ rdb *redis.Client }

func (r *redisNonces) IssueNonce() string         { /* ... */ }
func (r *redisNonces) Validate(nonce string) bool { /* ... */ }

op.WithDPoPNonceSource(&redisNonces{rdb: client})

See examples/51-dpop-nonce for the in-memory shape; the production replacement matches the same interface.

Session placement

Sessions can live durably (SQL) or volatile (Redis without persistence). The trade-off:

PlacementProsCons
Durable (SQL)survives restart / failover, so the browser logout trigger can still resolve its subject and session snapshot; RP targets come from grants, not a session listevery login round-trips your DB write
Volatile (Redis)low write latency; no DB hot roweviction on restart / maxmemory can lose the browser logout trigger before the snapshot; once a snapshot exists, RP targets still come from grants, not SessionStore rows

Use WithSessionDurabilityPosture(...) to annotate the choice in audit events (bcl.no_sessions_for_subject records a session-bearing notice with zero grant-derived RP targets). The library does not constrain placement. Durable placement protects the browser logout trigger and the pre-fan-out snapshot across restart and failover. Volatile eviction can lose that trigger before the fan-out starts. Once a snapshot exists, target resolution is grant-derived rather than a walk of SessionStore rows; the posture gives SOC dashboards context for trigger loss versus a no-target outcome.

Interaction state

The /interaction/{uid} flow stores per-attempt state under a uid cookie. With a single replica, this can live in process memory. With N replicas, you have two options:

  1. Sticky sessions on the load balancer. Route every request carrying the same uid cookie to the same replica. Simple but replica failure mid-login surfaces as a generic error to the user.
  2. Shared interaction store. Implement store.InteractionStore against Redis (or use the bundled Redis adapter). Any replica can resume any login. This is the default recommendation for production.

The Redis adapter's InteractionStore is volatile-eligible and lives in the volatile slice of a composite store.

Graceful shutdown and detached logout

RP-initiated logout may leave Back-Channel Logout deliveries running after the browser response. Stop the HTTP server before draining the provider:

go
srv.Shutdown(ctx)      // stop accepting
provider.Shutdown(ctx) // then drain detached fan-out

WithBackchannelFanOutBudget bounds one whole detached fan-out (30 seconds by default), while WithBackchannelLogoutTimeout bounds each RP request independently. Provider.Shutdown(ctx) waits for fan-outs already in flight; use the shared session and grant stores on every replica so target resolution is consistent during the drain.

Every replica MUST share the same WithCookieKeys slice. A replica that decrypts with a different key returns invalid_session for cookies it didn't encrypt — at scale this looks like random user logouts.

Source the key from your secret manager and inject it identically into every replica:

go
key, err := loadFromSecretManager("/op/cookie/current")
if err != nil { log.Fatal(err) }
op.WithCookieKeys(key)

Rotation across N replicas: deploy WithCookieKeys(new, old) to every replica simultaneously, then deploy WithCookieKeys(new) after the overlap window. See Key rotation.

Load-balancer affinity

EndpointAffinity needed?
/.well-known/openid-configuration, /jwksno — pure read
/authorize, /par, /end_sessionno, if interaction state lives in shared Redis; yes if process-local
/token, /userinfo, /introspect, /revokeno
/register, /register/{client_id}no
/interaction/{uid}sticky to the replica the /authorize redirect landed on, unless Redis-backed

The simplest production shape: round-robin everywhere + Redis-backed interaction store. The next-simplest: sticky on the uid cookie and process-local interaction state.

Health checks

The OP itself does not mount a health endpoint. Common patterns:

  • Liveness: any 2xx from /.well-known/openid-configuration. The discovery doc renders without store access.
  • Readiness: include a store ping. The library does not expose a store-wide health method — implement one in your embedder layer:
go
func ready(store *MyComposite) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
        defer cancel()
        if err := store.Ping(ctx); err != nil {
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    }
}

Mount on a separate path (/healthz/ready) and exclude it from the public router.

Capacity planning

Rough sizing on commodity hardware (sustained throughput, not peak):

EndpointRPS / replicaBottleneck
/jwks, discoveryseveral thousandstatic JSON; CDN-friendly
/authorize (no interaction)low hundredsDB write for code + session
/token (authorization_code)hundredscrypto for ID-token sign + DB writes
/token (refresh_token)several hundredcrypto + rotation write
/userinfoseveral hundredbearer verify + UserStore lookup

Numbers are illustrative — your bottleneck is almost always the durable store, not the OP. Profile with go test -bench against your own store implementation before sizing.

What you can't do with multiple instances

  • Run two OPs against one transactional store with different configurations. The discovery document, scope catalog, alg list, and grant set must agree across replicas. Differences cause RP-visible drift (a token a replica issues another rejects).
  • Split composite.TxClusterKinds across two backends. The composite adapter rejects a split cluster, a non-comparable cluster backend, or an anchor that does not implement store.Transactional. Durable kinds outside that cluster may be routed separately when their own consistency and availability requirements are met. See Hot/cold split.