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 question from "what is in process memory" to "what must every replica see, what may be lost, and how does a logout reach every RP".
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
- Shared Signals streams and events (both on one backend)
Production replicas must still use shared placement wherever cross-replica visibility is required.
The in-memory store (inmem) is process-local, so with more than one replica Sessions and Interactions must sit on a shared backend: SQL, DynamoDB, or Redis.
The substores the Redis adapter implements are Sessions, Interactions, and ConsumedJTIs (the JAR / DPoP / private_key_jwt replay set), plus Metadata and the two Shared Signals substores (see Shared Signals); see Hot/cold split. Sessions and Interactions tolerate eviction. ConsumedJTIs does not, and neither do the Shared Signals queue (an evicted event is a SET never delivered) or Metadata: evicting a live marker reopens the replay window it closed, so run it on a noeviction Redis instance or on a durable backend. The DPoP server-nonce store is a separate seam wired through op.WithDPoPNonceSource, not a substore; the same Redis store provides one as DPoPNonceSource (see DPoP server-nonce store).
The authn-factor lockout counter is not part of the store.Store substore set either, but op.New picks it up from the WithStore value when that value has an AuthnLockouts() method. The in-memory, SQL and DynamoDB adapters do, and composite.Store forwards the one on the backend routed for the AuthnLockouts kind (unrouted, it follows WithDefault, then the Clients backend), so route that kind to a durable backend and a hot/cold composition needs no further wiring. op.WithAuthnLockoutStore overrides the automatic choice, and is how you attach a store when the WithStore value has none (the Redis adapter does not implement it):
op.WithAuthnLockoutStore(storage.AuthnLockouts())If no store resolves while the login flow has a counter-using step, op.New refuses to start with op.ErrAuthnLockoutStoreRequired, so brute-force limiting cannot be silently off. The bundled SQL and DynamoDB adapters provide durable, replica-shared implementations. 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
| Concern | Single replica | N replicas |
|---|---|---|
| DPoP server nonce | in-memory reference source ships with the library | needs distributed source |
| Session cookies | encrypted with WithCookieKeys; shared across replicas as long as the key matches | same — every replica must share the cookie key |
| Session records | any store | a shared store (SQL, DynamoDB, Redis); inmem is per process |
Interaction state (/interaction/{uid}) | any store, including inmem | a shared store (SQL, Redis) or sticky routing |
| Rate limiting | upstream / out-of-process | upstream / out-of-process |
| OFCS conformance harness | runs against one OP | runs 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:
- Disable the server-nonce flow. Don't pass
WithDPoPNonceSource. Clients then proceed without server-supplied nonces. This is fine when you don't need RFC 9449 §8 hardening, but the FAPI 2.0 Message Signing and FAPI-CIBA profiles require a source, andop.Newrefuses to start without one while DPoP is enabled. - Share one source through Redis.
op/storeadapter/redisprovidesStore.DPoPNonceSource(rotate), anop.DPoPNonceSourcewhose current and previous nonce live in one Redis key under the store's key prefix. Every replica that points at the same prefix issues and accepts the same values. Any other shared store needs your ownop.DPoPNonceSource, which is two methods (IssueNonce,Validate).
nonces, err := redisStore.DPoPNonceSource(time.Minute) // op/storeadapter/redis
if err != nil {
log.Fatal(err)
}
op.WithDPoPNonceSource(nonces)With a source wired, /token, /par, and /userinfo reject a DPoP proof whose nonce claim is missing or not accepted, answering use_dpop_nonce with a fresh value in the DPoP-Nonce header. See examples/51-dpop-nonce for that handshake with the in-memory source, and DPoP nonce flow for how the Redis source behaves when Redis is unreachable or evicts its key.
Session placement
Sessions can live durably (SQL) or volatile (Redis without persistence). The trade-off:
| Placement | Pros | Cons |
|---|---|---|
| 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 list | every login round-trips your DB write |
| Volatile (Redis) | low write latency; no DB hot row | eviction 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 declare the choice (the default is volatile); the value is stamped on bcl.no_sessions_for_subject, which records a session-bearing logout 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
A login in progress (/interaction/{uid}) keeps its state in the store.InteractionStore, and the browser carries the uid in the sealed __Host-oidc_interaction cookie. With the in-memory store that state is process-local. With N replicas you have two options:
- Sticky routing on the load balancer. Send every request for the same
uid(it is in the path) to the same replica. Simple, but a replica failure mid-login surfaces as a generic error to the user. - Shared interaction store. Use the SQL adapter, or the Redis adapter in the volatile slice of a composite store. Any replica can resume any login. This is the default recommendation for production.
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:
srv.Shutdown(ctx) // stop accepting
provider.Shutdown(ctx) // then drain detached fan-outBackchannelLogout.FanOutBudget bounds one whole detached fan-out (30 seconds by default), while BackchannelLogout.Timeout bounds each RP request independently (5 seconds by default); both are fields of the value passed to WithBackchannelLogout. Provider.Shutdown(ctx) waits for fan-outs already in flight and does not stop the provider from serving, which is why the HTTP server stops first. Use the shared session and grant stores on every replica so target resolution is consistent during the drain.
Cookie key consistency
Every replica MUST share the same WithCookieKeys slice. A replica holding a different key cannot open cookies it didn't seal and treats the browser as signed out, so at scale this looks like random user logouts.
Source the key from your secret manager and inject it identically into every replica:
key, err := loadFromSecretManager("/op/cookie/current")
if err != nil { log.Fatal(err) }
op.WithCookieKeys(key)A rolling deploy makes rotation across N replicas a three-step rollout, because a replica must be able to open a cookie before any other replica starts sealing with its key:
- Deploy
WithCookieKeys(old, new)everywhere. Nothing seals withnewyet, but every replica can open it. - Deploy
WithCookieKeys(new, old). Replicas now seal withnewand still open old cookies. - After the longest session has ended, deploy
WithCookieKeys(new).
See Key rotation for the cadence and the overlap length.
Static client changes
op.New compares the WithStaticClients seeds with the stored records at construction and fails with a configuration conflict (ErrConflict) when they differ. After op.SyncStaticClients writes changed metadata or a new secret, an old-version replica that restarts with the old seeds therefore fails to start, and so does a new-version replica that starts before the sync. Replicas that are already running keep serving.
Run op.SyncStaticClients with the new seeds immediately before the rollout, keep old-version replicas from restarting or scaling out until they are replaced, and run the sync with the old seeds first if you roll back. A deploy with unchanged seeds needs no sync. See Changing a static client.
Load-balancer affinity
| Endpoint | Affinity needed? |
|---|---|
/.well-known/openid-configuration, /jwks | no — pure read |
/auth, /par, /end_session | no, if interaction and session state live in a shared store; yes if process-local |
/token, /userinfo, /introspect, /revoke | no |
/device_authorization, /bc-authorize | no |
/register, /register/{client_id} | no |
/interaction/{uid} | sticky to the replica the /auth redirect landed on, unless the interaction store is shared |
Paths are the defaults, relative to the mount prefix (/oidc by default). The simplest production shape is round-robin everywhere with a shared interaction store. The next simplest is sticky routing on the uid with 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 on a type of your own (
MyCompositebelow is such a type, not a library one):
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
What each endpoint costs per request, to help you decide what to measure:
| Endpoint | Dominant cost |
|---|---|
/jwks, discovery | static JSON, no store access; CDN-friendly |
/auth (no interaction) | store writes for the code and the session |
/token (authorization_code) | ID-token signature, plus store writes and a user lookup |
/token (refresh_token) | signature plus the rotation write, in one transaction |
/userinfo | access-token verification plus a user lookup |
The bottleneck is almost always the durable store rather than the OP, and a KMS-backed signer adds its own latency to every signature (see JWKS endpoint). Load-test against your own store before sizing; this library publishes no throughput figures.
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.TxClusterKindsacross two backends. The composite adapter rejects a split cluster, a non-comparable cluster backend, or an anchor that does not implementstore.Transactional. Durable kinds outside that cluster may be routed separately when their own consistency and availability requirements are met. See Hot/cold split.