Skip to content

Architecture overview ​

op.New(...) returns an http.Handler backed by an http.ServeMux. This page walks through what happens between request arrival and response — the packages involved, the order of validation, and the storage seams the embedder controls.

The RP in the sequence below is a separate application. The OP application embeds go-oidc-provider and owns the Store and LoginFlow implementations.

Package layout ​

op/                         ← public API surface (this is what you import)
op/profile/                 ← OAuth 2.1 / OIDC Core / FAPI 2.0 / FAPI-CIBA profiles
op/feature/                 ← PKCE / PAR / JAR / JARM / DPoP / mTLS / introspect / revoke
op/grant/                   ← authorization_code, refresh_token, client_credentials, device_code, CIBA, JWT bearer
op/audit/                   ← audit event constants and the event catalog
op/authn/, op/ciba/         ← contract types the embedder implements (Authenticator, HintResolver, ...); re-exported by op
op/subject/                 ← public / pairwise subject generators
op/rs/                      ← resource-server side: access-token verifier and middleware
op/store/                   ← Store interface (substores) + contract test suite (op/store/contract)
op/storeadapter/{inmem,sql,redis,dynamodb,composite}
                            ← store adapters; patterns holds helpers for adapter authors
op/interaction/             ← HTML / JSON driver seam for login UI
op/devicecodekit, op/passkeykit, op/totpkit, op/recoverykit
                            ← helpers for the parts the embedder owns (device verification page, factor enrollment)
op/testkit/                 ← a ready-made provider for tests
cmd/op-client-secret        ← mints a client secret and its stored hash for static clients
op/storeadapter/sql/cmd/op-schema
                            ← writes the SQL adapter's migration files for golang-migrate, goose or Flyway

internal/                   ← cannot be imported externally (Go visibility)
  authn/                    ← LoginFlow orchestrator, Authenticator runtime
  authorizeendpoint, parendpoint, tokenendpoint, userinfo,
  introspectendpoint, revokeendpoint, registrationendpoint,
  devicecodeendpoint, cibaendpoint, grantmgmtendpoint, protectedresource,
  endsession, backchannel, ssf
  jose, jwks, keys          ← signing / verification / key set
  jar, jarm, dpop, mtls, pkce, sessions, cimd, assertiongrant
  cookie, csrf, cors, httpx, redact, log, metrics
  discovery, scoperegistry, timex, i18n

The boundary is enforced structurally: external code cannot reach into internal/. Every embedder-controlled seam (option, store interface, authenticator, audit subscriber) is in op/ or one of its subpackages.

Routes ​

op.New constructs a *http.ServeMux and mounts one handler per configured path. The paths below are the defaults. Every protocol endpoint sits under the issuer's path plus the mount prefix (/oidc unless WithMountPrefix says otherwise), so /token on issuer https://idp.example.com is https://idp.example.com/oidc/token; WithEndpoints renames individual paths. Discovery and protected-resource metadata are the exceptions: they live at their well-known locations, outside the prefix.

PathHandler packageWhat it servesMounted
/.well-known/openid-configurationinternal/discoverythe OP's effective configuration as a discovery documentalways
/jwksinternal/jwksthe public half of the signing keysetalways
/.well-known/oauth-protected-resource (+ path suffix)internal/protectedresourceRFC 9728 metadata for each declared protected resourceWithProtectedResources
/authinternal/authorizeendpointrequest parsing, client and redirect_uri validation, the redirect into interactionwhen the authorization_code grant is enabled
/interaction/{uid}internal/authorizeendpoint + the op/interaction driverthe login and consent ceremony, rendered as HTML or served as JSON to an SPAwith /auth
/parinternal/parendpointpushed authorization requests, returning a one-time request_urifeature.PAR
/tokeninternal/tokenendpointclient authentication, code and refresh redemption, token issuancealways
/userinfointernal/userinfoclaims for the subject behind a presented access tokenalways
/revokeinternal/revokeendpointRFC 7009 revocation of a refresh or access tokenfeature.Revoke
/introspectinternal/introspectendpointRFC 7662 token state for an authorized resource serverfeature.Introspect
/device_authorizationinternal/devicecodeendpointRFC 8628 device authorization: returns a device_code and user_codedevice-code grant
/bc-authorizeinternal/cibaendpointCIBA backchannel authentication request, returns an auth_req_idCIBA grant
/grant_management/{grant_id}internal/grantmgmtendpointquery or revoke one grantWithGrantManagement
/end_sessioninternal/endsessionRP-initiated logout, including the back-channel fan-outwith /auth (the authorization_code grant)
/registerinternal/registrationendpointdynamic client registration and the per-client management routesWithDynamicRegistration
/ssf/streams
/ssf/status
/ssf/verify
/ssf/poll/{stream_id}
internal/ssfShared Signals transmitter: stream configuration, status, verification and pollingWithSharedSignals
  • /jwks: ECDSA P-256 by default, RSA with RS256, and both while switching (plus use=enc keys when configured).
  • /interaction/{uid}: With op.SPAUI the route moves to LoginMount.
  • /ssf/*: /.well-known/ssf-configuration serves the metadata, outside the prefix.

Back-channel logout has no row of its own because it is not a mounted endpoint. It is an outbound fan-out that /end_session triggers: the OP POSTs a logout token to each RP's registered backchannel_logout_uri. The discovery document advertises only the endpoints that are actually mounted, so a deployment that leaves feature.Introspect off never announces /introspect. Provider.Routes() returns the http.ServeMux patterns the provider registered for the current configuration, for mounting on a router that dispatches by path; op.NewReloadable serves a provider that can be swapped for a rebuilt one to pick up rotated keys.

Cross-cutting middleware ​

Every handler is wrapped by:

LayerSourceRole
CORSinternal/corspublic CORS for discovery and /jwks; strict allow-list for /userinfo, /token, interaction/session JSON surfaces, and mounted protocol endpoints such as /par, /revoke, /introspect, /register, /bc-authorize, /device_authorization, and /end_session
Trusted proxyinternal/httpxresolves real client IP from X-Forwarded-For / -Proto / -Host based on WithTrustedProxies
Cookieinternal/cookie__Host- prefix, AES-256-GCM, SameSite=Lax for session, Strict where compatible
CSRFinternal/csrfdouble-submit + Origin / Referer check on the consent / logout POST

These are not optional — they apply structurally regardless of which options the embedder set.

The origin sets are deliberately separate. API CORS is widened only by explicit WithCORSOrigins (with redirect-URI origins included for API compatibility); interaction and end-session ceremony checks allow the issuer origin plus those explicit origins only. A redirect URI's origin never authorizes a cross-origin interaction or logout POST by itself.

Authorize → token lifecycle ​

The most-trodden path. Roughly:

Authorize and interaction: where the OP calls your code
The browser drives the authorize request and the interaction. The OP looks up the client and checks the request, hands the login to your LoginFlow, and saves the authorization code to your store; those calls all land in the right-hand lane, which is your code.authorize requestinteractionUser agentbrowserOPgo-oidc-providerYour codeStore · LoginFlowGET /authClients.GetClientexact-match redirect_uriCheck the requestPKCE · scoperesponse_type302 → /interaction/{uid}POST /interaction/{uid}loginBegin / Continuethe Step chainResultsubject · AAL · AMR200 consent pagePOST /interaction/{uid}consentAuthorizationCodes.Savecode + PKCE challenge
The right-hand lane is yours. Every arrow that reaches it is an interface call, which is what makes the storage backend and the login experience replaceable without touching the protocol path in the middle.
Redeeming the code: client auth comes before consume
The authorization code returns to the RP through the browser, then the RP redeems it directly at /token. The OP authenticates the client, consumes the code, registers or saves the tokens in your store, and answers with the token response.the code returns via the browserredeeming the codeRPseparate appOPgo-oidc-providerStoreyours302 → redirect_uricode · state · issPOST /tokengrant_type=authorization_codeClients.GetClientauthenticate the client firstAuthorizationCodes.Consumethen PKCE and redirect_uriAccessTokens.Registeronly with JTIRegistryRefreshTokens.Savewhen eligible200 · access_token · id_token+ refresh_token when eligible
The code travels through the browser but is redeemed on a direct back channel. Client authentication happens before the code is consumed, and PKCE and redirect_uri are enforced after it.

/par and /end_session follow the same general shape; the sequence diagram is the canonical happy path.

Each authorization carries its own authentication state. The code, the refresh chain it starts and the access tokens it issues record the auth_time, acr, amr, authorization_details and OIDC claims request of the authorization that created them, rather than reading the one grant per (subject, client) that every later authorization rewrites. A second login by the same user does not change what an earlier code reports in its ID token, and revoking or rotating one refresh chain leaves the user's other sessions on the same client alone.

A JWT access token gets an AccessTokens shadow row only when RevocationStrategyJTIRegistry is selected. The default RevocationStrategyGrantTombstone records a grant tombstone at revocation instead, while RevocationStrategyNone disables server-side JWT revocation. A refresh token is saved only when the provider grant, client registration, and scope policy allow it.

Requests served from an existing session ​

/auth does not always run the chain. It can mint straight from a live session when a cached grant covers the request or the first-party consent skip applies. LoginFlow rules and the Decider still apply on that path:

An authorize request served from an existing session
When /auth can mint from a live session, the LoginFlow rules and the Decider are evaluated first, as if the primary step had just completed. If nothing demands another step the code is minted. If a step is demanded, prompt=none answers interaction_required, and any other request runs the chain from the session's subject before minting.Session reuseda cached grant covers itfirst-party consent skipRules and Decideras if Primary just completedamr shows what ranMint the codeno step demandedinteraction_requiredstep demanded, prompt=noneRun the chainstep demanded, prompt is not none
Risk-driven rules fire only inside the chain, so they never trigger on this path. The chain starts from the session's subject, and a factor that returns a different subject than the one already bound ends it with a server error.

A Rule predicate or Decider that panics fails closed: a panicking Rule.When counts as a match so the step is still demanded, and a panicking Decider denies. A Decider that returns nil or an unknown shape also denies.

LoginFlow internals ​

WithLoginFlow(LoginFlow{...}) is compiled at construction time into an internal pipeline:

LoginFlow {Primary, Rules[], Decider, Risk}
    │
    ▼ (compile)
internal/authn/CompiledLoginFlow
    ├── Primary  → Authenticator (resolves Step descriptor → runtime impl)
    ├── Rules[]  → ordered (When, Then) pairs
    ├── Decider  → optional short-circuit
    └── Risk     → invoked once at chain start; score is cached
LoginFlow: declared once, compiled once, driven per request
What you pass to WithLoginFlow is a specification. It is compiled once into a CompiledLoginFlow, which the orchestrator then drives in a loop for each request: begin the primary factor, prompt, bind an identity, evaluate the decider and the rules, and repeat until no rule fires.declaredcompiled oncedriven per requestLoginFlowPrimarythe first factorRules[]when to ask for moreDecider · RiskAAL policy and signalscompileCompiledLoginFlowinternal/authnresolved at op.NewPrimary.Begin → Steponce, at the start of the chainPrompt, the user submitsbinds subject · AAL · AMRDecider, then Rulesa rule fires → another StepNo rule firesthe session is issued
Compiling up front is what makes a misconfigured flow a start-up error rather than a runtime one. By the time a request arrives there is nothing left to interpret — only a chain to walk.

For each authorize request:

  1. Primary.Begin produces an interaction.Step (Prompt or Result).
  2. UI driver (HTML or React) renders the prompt; the user submits.
  3. Primary.Continue advances to a Result carrying the bound Identity.
  4. Orchestrator builds a LoginContext (subject, scopes, completed steps, cached risk score, ACR values).
  5. Decider runs (if non-nil); a non-Pass decision short-circuits. A factor that returns a different subject than the one already bound ends the chain with a server error, and the AAL3 user-verification check applies to a factor that completes in Begin without a prompt as well.
  6. Otherwise Rules evaluate in order; the first matching rule whose Step.Kind() is not in CompletedSteps fires.
  7. Loop until no rule fires; the session is then issued. LoginFlow.Risk is consulted at most once for the chain and its outcome is cached.

See Use case: Custom authenticator for how to plug your own factor in via ExternalStep.

Storage seams ​

The library never reads or writes the embedder's users table directly. It talks to the store.Store interface, which is the union of small substores. End-user records are not one of them: the user store comes from op.WithUserStore or from the Store of an op.PrimaryPassword in the login flow, and commonly maps to an existing users table (see Bring your own user store).

SubstoreWhat lives thereAdapter notes
ClientsOAuth client registrytypically durable
AuthorizationCodesone-shot codes (PKCE challenge, scope, per-authorization auth state)durable
RefreshTokensrefresh chains (each with a ChainID), rotation historydurable
AccessTokensper-jti registry for JWT access tokens, used by RevocationStrategyJTIRegistrydurable
OpaqueAccessTokensopaque access-token records (each with the ChainID of the chain that minted it)durable
Grantsconsented scopes per (user, client), including the grants that device-code, CIBA and token-exchange redemptions recorddurable
GrantRevocationstombstones for revoked grants, optionally scoped to access tokens onlydurable
Sessionsbrowser session recordsvolatile-eligible
Interactionsper-attempt interaction statevolatile-eligible
ConsumedJTIsJAR / DPoP jti replay setdurable, or a Redis instance set to noeviction
PushedAuthRequestspushed authorization requestsdurable when PAR participates in an authorization-code transaction
InitialAccessTokens / RegistrationAccessTokensDCR Initial / Registration Access Tokensdurable
DeviceCodesRFC 8628 device-authorization recordsdurable
CIBARequestsOpenID Connect CIBA backchannel-authentication recordsdurable
MetadataOP-internal key/value state (e.g. the subject_mode marker)durable, may be absent (nil)
SSFStreams / SSFEventsShared Signals stream configuration and the outbound event queue; both on one storage, through the optional store.SharedSignalsStoredurable; required only with WithSharedSignals
AuthnLockoutsper-subject authentication failure counters, resolved through store.AuthnLockoutProviderdurable

Volatile-eligible substores can live in a Redis tier behind the composite adapter. At construction time, the composite adapter requires every Kind to have a route; the Shared Signals kinds and AuthnLockouts, when unrouted, follow WithDefault or else the Clients backend. The seven members of composite.TxClusterKinds must all resolve to the same comparable backend, and that backend must implement store.Transactional:

  • authorization codes
  • refresh tokens
  • grants
  • PARs
  • JWT access-token registrations
  • opaque access tokens
  • grant-revocation tombstones

Other kinds may use separate backends. Invalid kinds, missing routes, a split cluster, a non-comparable cluster backend, and a non-transactional anchor are reported as construction errors.

Revocation scope and optional store interfaces ​

A grant is shared by every session of one subject on one client, so a revocation that works per grant would end sessions the caller never meant to touch. Two optional interfaces let a store keep revocation narrower:

InterfaceOnWhat it adds
store.OpaqueAccessTokenChainRevokerOpaqueAccessTokens()RevokeByChain: retire the opaque access tokens of one refresh chain
store.IssuanceRevocationCheckerGrantRevocations()IsIssuanceRevoked: an access-token-only tombstone (GrantTombstone.AccessTokensOnly) does not refuse refresh or code redemption

The in-memory, SQL and DynamoDB adapters implement both. A bring-your-own store that implements neither, or that drops ChainID / AccessTokensOnly, keeps the grant-wide behaviour. The same holds for AuthorizationRecorded on codes, refresh tokens and opaque access tokens: a row without the per-authorization fields falls back to the grant. What each trigger reaches:

TriggerRefresh tokensAccess tokensFurther issuance under the grant
refresh rotationthe predecessor retires; the chain continuesopaque tokens of that chain only (the grant's, when the store lacks the chain revoker)open
/revoke on a refresh tokenthat chain (the grant's refresh tokens when the chain root cannot be resolved)every access token of the grantopen: other chains and unredeemed codes still redeem
replayed refresh tokenthe replayed chainevery access token of the grantclosed
replayed authorization codeevery refresh token of the grantevery access token of the grantclosed
grant revoke (RevokeSubject, Grant Management revoke, /end_session teardown)allallclosed

RevokeSubject retires grants and tokens only. To erase everything the OP holds for a deleted user, including browser sessions, the lockout record and factor records, call Provider.ForgetSubject once the user stores no longer resolve the subject.

A SQL deployment applies the migration files from Store.Migrations() (or writes them for its own migration tool with op-schema export) before upgrading, because op.New refuses to start without the new columns.

MFA factor stores (EmailOTPStore, TOTPStore, PasskeyStore, RecoveryStore, AuthnLockoutStore) are not substores of store.Store. They are supplied directly to the matching login-flow values (StepEmailOTP.Store, StepTOTP.Store, PrimaryPasskey.Store, StepRecoveryCode.Store, and WithAuthnLockoutStore) when the embedder builds the LoginFlow; the lockout store is also taken from the WithStore value's AuthnLockouts() when it has one. In-memory, SQL, and DynamoDB adapters expose these stores with matching accessors. examples/27-durable-mfa-store uses the shipped SQL factor stores with the core OP tables in one database; custom backends implement the same contracts themselves.

See Architecture: storage tiering for production placement guidance.

Discovery document assembly ​

The discovery handler at /.well-known/openid-configuration builds its document from the OP's effective configuration. Every advertised field is the authoritative answer for what the OP will actually do — there is no drift between discovery and behaviour because:

  • response_types_supported is computed from WithGrants + the FAPI profile; a client-credentials-only OP advertises an empty list and omits browser authorization/logout surfaces.
  • token_endpoint_auth_methods_supported is intersected with the library's FAPI allow-list when WithProfile(profile.FAPI2Baseline) or WithProfile(profile.FAPI2MessageSigning) is active.
  • scopes_supported is the union of built-in scopes and WithScope registrations.
  • ui_locales_supported is auto-derived from the runtime locale resolver (seed bundles plus WithLocaleBundle additions) unless WithDiscoveryMetadata(...).UILocalesSupported supplies an explicit non-empty override.
  • code_challenge_methods_supported is always ["S256"] — plain is structurally absent.
  • id_token_signing_alg_values_supported, userinfo_signing_alg_values_supported, authorization_signing_alg_values_supported and introspection_signing_alg_values_supported each list only the OP's issuing algorithm: ES256 by default, RS256 after op.WithSigningAlgorithm(op.AlgRS256). /jwks may carry both key types while an OP is switching between them, but discovery names only the algorithm in use.
  • request_object_signing_alg_values_supported is the JOSE allow-list (RS256, PS256, ES256, EdDSA).
  • dpop_signing_alg_values_supported is narrower (ES256, EdDSA, PS256) — see FAQ § DPoP discovery.