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".
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():
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
| 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 |
Interaction state (/interaction/{uid}) | typically in-memory | needs Redis or sticky sessions |
| 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 the safe default when you don't need RFC 9449 §8 hardening. - Plug a distributed source. Implement
op.DPoPNonceSourceagainst 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.
// 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:
| 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 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:
- Sticky sessions on the load balancer. Route every request carrying the same
uidcookie to the same replica. Simple but replica failure mid-login surfaces as a generic error to the user. - Shared interaction store. Implement
store.InteractionStoreagainst 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:
srv.Shutdown(ctx) // stop accepting
provider.Shutdown(ctx) // then drain detached fan-outWithBackchannelFanOutBudget 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.
Cookie key consistency
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:
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
| Endpoint | Affinity needed? |
|---|---|
/.well-known/openid-configuration, /jwks | no — pure read |
/authorize, /par, /end_session | no, if interaction state lives in shared Redis; yes if process-local |
/token, /userinfo, /introspect, /revoke | no |
/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:
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):
| Endpoint | RPS / replica | Bottleneck |
|---|---|---|
/jwks, discovery | several thousand | static JSON; CDN-friendly |
/authorize (no interaction) | low hundreds | DB write for code + session |
/token (authorization_code) | hundreds | crypto for ID-token sign + DB writes |
/token (refresh_token) | several hundred | crypto + rotation write |
/userinfo | several hundred | bearer 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.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.