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.
Package layout
op/ ← public API surface (this is what you import)
op/profile/ ← FAPI 2.0 / future profiles
op/feature/ ← PAR / DPoP / mTLS / introspect / revoke / DCR / JAR
op/grant/ ← authorization_code, refresh_token, client_credentials
op/store/ ← Store interface (substores) + contract test suite
op/storeadapter/{inmem,sql,redis,composite}
op/interaction/ ← HTML / JSON driver seam for login UI
internal/ ← cannot be imported externally (Go visibility)
authn/ ← LoginFlow orchestrator, Authenticator runtime
authorize, parendpoint, tokenendpoint, userinfo,
introspectendpoint, revokeendpoint, registrationendpoint,
endsession, backchannel
jose, jwks, keys ← signing / verification / key set
jar, dpop, mtls, pkce, sessions
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.
Handler graph
op.New constructs a *http.ServeMux and mounts handlers on the configured paths (defaults shown):
Endpoints gated by features (PAR, Introspect, Revoke, DynamicRegistration, BackChannelLogout) are mounted only when the corresponding feature.* is enabled or the corresponding option (WithDynamicRegistration) is supplied. The discovery document only advertises endpoints that are actually mounted.
Cross-cutting middleware
Every handler is wrapped by:
| Layer | Source | Role |
|---|---|---|
| CORS | internal/cors | strict allowlist for /token, /userinfo, /revoke, /introspect; public CORS for /jwks and discovery |
| Trusted proxy | internal/httpx | resolves real client IP from X-Forwarded-* / Forwarded 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.
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.
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 per evaluation passFor 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, risk score, ACR values). Deciderruns (if non-nil); a non-Passdecision short-circuits.- Otherwise
Rulesevaluate in order; the first matching rule whoseStep.Kind()is not inCompletedStepsfires. - Loop until no rule fires; the session is then issued.
See Use case: Custom authenticator for how to plug your own factor in via ExternalStep.
Storage seams
The library never reads or writes your users table directly. It talks to the op.Store interface which is the union of small substores:
| Substore | What lives there | Adapter notes |
|---|---|---|
Clients | OAuth client registry | typically durable |
Users | subjects + claims | embedder-implemented; commonly maps to existing users table |
AuthorizationCodes | one-shot codes (PKCE challenge, scope) | durable |
RefreshTokens | refresh chains, rotation history | durable |
AccessTokens | JWT id-side / opaque tokens | durable |
OpaqueAccessTokens | opaque AT lookup | durable |
Grants | consented scopes per (user, client) | durable |
GrantRevocations | tombstones for revoked grants | durable |
Sessions | browser session records | volatile-eligible |
Interactions | per-attempt interaction state | volatile-eligible |
ConsumedJTIs | JAR / DPoP jti replay set | volatile-eligible |
PARs | pushed authorization requests | volatile-eligible |
IATs / RATs | DCR Initial / Registration Access Tokens | durable |
EmailOTPs, TOTPs, Passkeys, Recovery | per-user MFA factor records | durable |
Volatile-eligible substores can live in a Redis tier behind the composite adapter. The composite store enforces a single durable backend at construction time so a transactional cluster cannot split across two stores.
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.token_endpoint_auth_methods_supportedis intersected with the FAPI allow-list whenWithProfile(profile.FAPI2Baseline)/FAPI2MessageSigningis active.scopes_supportedis the union of built-in scopes andWithScoperegistrations.code_challenge_methods_supportedis always["S256"]—plainis structurally absent.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 handler graph 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.