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, i18nThe 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.
| Path | Handler package | What it serves | Mounted |
|---|---|---|---|
/.well-known/openid-configuration | internal/discovery | the OP's effective configuration as a discovery document | always |
/jwks | internal/jwks | the public half of the signing keyset | always |
/.well-known/oauth-protected-resource (+ path suffix) | internal/protectedresource | RFC 9728 metadata for each declared protected resource | WithProtectedResources |
/auth | internal/authorizeendpoint | request parsing, client and redirect_uri validation, the redirect into interaction | when the authorization_code grant is enabled |
/interaction/{uid} | internal/authorizeendpoint + the op/interaction driver | the login and consent ceremony, rendered as HTML or served as JSON to an SPA | with /auth |
/par | internal/parendpoint | pushed authorization requests, returning a one-time request_uri | feature.PAR |
/token | internal/tokenendpoint | client authentication, code and refresh redemption, token issuance | always |
/userinfo | internal/userinfo | claims for the subject behind a presented access token | always |
/revoke | internal/revokeendpoint | RFC 7009 revocation of a refresh or access token | feature.Revoke |
/introspect | internal/introspectendpoint | RFC 7662 token state for an authorized resource server | feature.Introspect |
/device_authorization | internal/devicecodeendpoint | RFC 8628 device authorization: returns a device_code and user_code | device-code grant |
/bc-authorize | internal/cibaendpoint | CIBA backchannel authentication request, returns an auth_req_id | CIBA grant |
/grant_management/{grant_id} | internal/grantmgmtendpoint | query or revoke one grant | WithGrantManagement |
/end_session | internal/endsession | RP-initiated logout, including the back-channel fan-out | with /auth (the authorization_code grant) |
/register | internal/registrationendpoint | dynamic client registration and the per-client management routes | WithDynamicRegistration |
/ssf/streams/ssf/status/ssf/verify/ssf/poll/{stream_id} | internal/ssf | Shared Signals transmitter: stream configuration, status, verification and polling | WithSharedSignals |
/jwks: ECDSA P-256 by default, RSA withRS256, and both while switching (plususe=enckeys when configured)./interaction/{uid}: Withop.SPAUIthe route moves toLoginMount./ssf/*:/.well-known/ssf-configurationserves 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:
| Layer | Source | Role |
|---|---|---|
| CORS | internal/cors | public 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 proxy | internal/httpx | resolves real client IP from X-Forwarded-For / -Proto / -Host based on WithTrustedProxies |
| Cookie | internal/cookie | __Host- prefix, AES-256-GCM, SameSite=Lax for session, Strict where compatible |
| CSRF | internal/csrf | double-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:
/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:
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 cachedFor each authorize request:
Primary.Beginproduces aninteraction.Step(Prompt or Result).- UI driver (HTML or React) renders the prompt; the user submits.
Primary.Continueadvances to aResultcarrying the boundIdentity.- Orchestrator builds a
LoginContext(subject, scopes, completed steps, cached risk score, ACR values). Deciderruns (if non-nil); a non-Passdecision 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 inBeginwithout a prompt as well.- Otherwise
Rulesevaluate in order; the first matching rule whoseStep.Kind()is not inCompletedStepsfires. - Loop until no rule fires; the session is then issued.
LoginFlow.Riskis 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).
| Substore | What lives there | Adapter notes |
|---|---|---|
Clients | OAuth client registry | typically durable |
AuthorizationCodes | one-shot codes (PKCE challenge, scope, per-authorization auth state) | durable |
RefreshTokens | refresh chains (each with a ChainID), rotation history | durable |
AccessTokens | per-jti registry for JWT access tokens, used by RevocationStrategyJTIRegistry | durable |
OpaqueAccessTokens | opaque access-token records (each with the ChainID of the chain that minted it) | durable |
Grants | consented scopes per (user, client), including the grants that device-code, CIBA and token-exchange redemptions record | durable |
GrantRevocations | tombstones for revoked grants, optionally scoped to access tokens only | durable |
Sessions | browser session records | volatile-eligible |
Interactions | per-attempt interaction state | volatile-eligible |
ConsumedJTIs | JAR / DPoP jti replay set | durable, or a Redis instance set to noeviction |
PushedAuthRequests | pushed authorization requests | durable when PAR participates in an authorization-code transaction |
InitialAccessTokens / RegistrationAccessTokens | DCR Initial / Registration Access Tokens | durable |
DeviceCodes | RFC 8628 device-authorization records | durable |
CIBARequests | OpenID Connect CIBA backchannel-authentication records | durable |
Metadata | OP-internal key/value state (e.g. the subject_mode marker) | durable, may be absent (nil) |
SSFStreams / SSFEvents | Shared Signals stream configuration and the outbound event queue; both on one storage, through the optional store.SharedSignalsStore | durable; required only with WithSharedSignals |
AuthnLockouts | per-subject authentication failure counters, resolved through store.AuthnLockoutProvider | durable |
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:
| Interface | On | What it adds |
|---|---|---|
store.OpaqueAccessTokenChainRevoker | OpaqueAccessTokens() | RevokeByChain: retire the opaque access tokens of one refresh chain |
store.IssuanceRevocationChecker | GrantRevocations() | 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:
| Trigger | Refresh tokens | Access tokens | Further issuance under the grant |
|---|---|---|---|
| refresh rotation | the predecessor retires; the chain continues | opaque tokens of that chain only (the grant's, when the store lacks the chain revoker) | open |
/revoke on a refresh token | that chain (the grant's refresh tokens when the chain root cannot be resolved) | every access token of the grant | open: other chains and unredeemed codes still redeem |
| replayed refresh token | the replayed chain | every access token of the grant | closed |
| replayed authorization code | every refresh token of the grant | every access token of the grant | closed |
grant revoke (RevokeSubject, Grant Management revoke, /end_session teardown) | all | all | closed |
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_supportedis computed fromWithGrants+ the FAPI profile; a client-credentials-only OP advertises an empty list and omits browser authorization/logout surfaces.token_endpoint_auth_methods_supportedis intersected with the library's FAPI allow-list whenWithProfile(profile.FAPI2Baseline)orWithProfile(profile.FAPI2MessageSigning)is active.scopes_supportedis the union of built-in scopes andWithScoperegistrations.ui_locales_supportedis auto-derived from the runtime locale resolver (seed bundles plusWithLocaleBundleadditions) unlessWithDiscoveryMetadata(...).UILocalesSupportedsupplies an explicit non-empty override.code_challenge_methods_supportedis always["S256"]—plainis structurally absent.id_token_signing_alg_values_supported,userinfo_signing_alg_values_supported,authorization_signing_alg_values_supportedandintrospection_signing_alg_values_supportedeach list only the OP's issuing algorithm:ES256by default,RS256afterop.WithSigningAlgorithm(op.AlgRS256)./jwksmay carry both key types while an OP is switching between them, but discovery names only the algorithm in use.request_object_signing_alg_values_supportedis the JOSE allow-list (RS256,PS256,ES256,EdDSA).dpop_signing_alg_values_supportedis narrower (ES256,EdDSA,PS256) — see FAQ § DPoP discovery.
Where to read next
- Options reference — every
op.With*in one table, with cross-links into the route table above. - Audit event catalog — what fires from each handler at each stage.
- Custom authenticator — how the orchestrator's pipeline calls into your code.
- Hot/cold storage — how the substore tiering interacts with the volatile / durable boundary above.