Skip to content

FAQ ​

Every question below came out of building the example suite or running the conformance harness. If you are stuck on one of them, the answer here is the one the project itself uses.

How to read this FAQ
A reading order for this FAQ: get the OP booted first, then move into whichever area you are working in, then finish at the error catalogue and the adoption questions.Boot it firstrequired optionsissuer · store · keysFAPI / DPoPprofiles, sender constraintsTokens / storageformats, TTLs, store choiceUI / SPA / MFAscreens and interactionErrorsfailures and what to changeAdoptionproduction · disclosure
Only the middle row is a choice: pick the area you are working in. Booting first, and the error and adoption pages at the end, apply to everyone.

Setup & basics ​

Why does op.New(...) return an error? ​

The core options have no safe default, so op.New refuses to start without them instead of falling back to zero values. WithIssuer, WithStore and WithKeyset are always required. When the authorization_code grant is enabled, which the default grant set does, WithCookieKeys and WithLoginFlow are required too:

OptionWithout it
WithIssuerThe OP has no identity to put in iss or in the discovery URL.
WithStoreThe OP has nowhere to persist clients, codes and tokens.
WithKeysetThe OP cannot sign ID tokens.
WithCookieKeysThe OP cannot seal the session and CSRF cookies of the browser flow.
WithLoginFlowNo user can sign in, so the OP refuses instead of failing the first real request.

The error names every missing option at once, so a typo at boot surfaces at start-up instead of as a runtime mystery. op.New also rejects combinations that cannot work, such as a second WithLoginFlow, or a statically seeded client_secret_basic client whose secret is missing or shorter than 32 bytes (op.NewClientSecret mints a suitable one). Required options walks through each.

Recommended pattern

Generate a 32-byte cookie key with crypto/rand once per environment and feed it through your config or secret manager. examples/01-minimal/main.go shows the complete setup, including the RP that drives it.

Where does the OP mount? Can I prefix it? ​

Mount the returned http.Handler at the root of your router and choose the URL prefix with op.WithMountPrefix. The default is /oidc; op.WithMountPrefix("/") serves the endpoints at the root. The discovery document is built from the issuer and the mount prefix, so RPs always see consistent URLs. Mount on your router lists where each endpoint lands.

What's the smallest config that boots? ​

go
st := inmem.New()
handler, err := op.New(
    op.WithIssuer("https://op.example.com"),
    op.WithStore(st),
    op.WithKeyset(myKeyset),
    op.WithCookieKeys(cookieKey), // 32 bytes
    op.WithLoginFlow(op.LoginFlow{
        Primary: op.PrimaryPassword{Store: st.UserPasswords()},
    }),
)

This is the minimum for a browser flow: the three always-required options, plus cookie keys and a login flow for the authorization-code grant. See Minimal OP.

"Issuer must not have a trailing slash" — really? ​

Really. RFC 9207 mix-up defence depends on byte-exact iss comparison across the whole ecosystem, so op.WithIssuer enforces a single canonical form. It rejects:

  • trailing slash (https://op/ → no);
  • mixed-case scheme (HTTPS://op → no);
  • mixed-case host (https://OP.example.com → no);
  • default port (https://op:443 → no, http://127.0.0.1:80 → no);
  • fragment (https://op#x → no);
  • query (https://op?x=1 → no);
  • non-canonical path (.., ., or duplicate slashes — checked via path.Clean).
Why so strict?

A single non-canonical character on either side of an RP's verification flips byte equality and breaks the mix-up defence silently. The error is at construction time so the misconfiguration can't reach production. See Design judgments §9.

FAPI 2.0 ​

What does op.WithProfile(profile.FAPI2Baseline) actually flip? ​

In one call, it applies this library's FAPI profile policy across these areas:

KnobEffect
Feature flagsenables feature.PAR and this library's JAR setting; selects feature.DPoP unless feature.MTLS is explicitly enabled
Client authnarrows token_endpoint_auth_methods_supported to private_key_jwt, plus tls_client_auth and self_signed_tls_client_auth when feature.MTLS is enabled
Alg constraintsnarrows client-signed request and assertion algorithms to the profile allow-list
redirect_urienforces exact match (no wildcarding)
PKCErequires S256 on every code request
state / nonceone of the two required on every authorize request
PAR/auth rejects a request that was not pushed to /par first
Lifetimescaps WithAccessTokenTTL at 10 minutes, and WithPARTTL must stay below 600 seconds
Client assertionsaud must be the issuer identifier as a single JSON string; an array or an endpoint URL is invalid_client
Refresh tokensnot rotated: a refresh response carries no refresh_token, and WithRefreshGracePeriod is rejected
DPoP htucompared with the endpoint URL the OP advertises, so a TLS-terminating proxy needs no DPoP-specific setting

The final FAPI 2.0 Security Profile requires PAR, PKCE with S256, sender-constrained tokens, an issuer-only string aud on client assertions, and no routine refresh-token rotation. The exact redirect_uri row is this library's policy; FAPI 2.0 §5.5 does not require pre-registration of exact values. JAR, this library's algorithm narrowing, and the refusal of RS256 issuance under the FAPI profiles are library profile settings, not requirements of the FAPI Security Profile.

Subsequent options that contradict the profile cause op.New to return an error.

The profile is intentionally rigid, so it cannot be partially relaxed by accident.

Baseline vs Message Signing — when do I need each? ​

In this library, Message Signing is profile.FAPI2Baseline plus mandatory signed request objects, JARM and signed introspection responses, and it also requires an op.WithDPoPNonceSource whenever DPoP is active; the nonce rule is this library's preset, not a requirement of the Final specification. Pick it when your RP needs non-repudiation of the authorization request and response — an open-banking style audit chain, for example. Otherwise the library's Baseline profile is enough. FAPI 2.0 Message Signing lists what changes for the embedder.

FAPI2Baseline (library profile)Message Signing
PAR + JAR (library setting) + PKCE (S256) + DPoP / mTLS+ signed request objects required, JARM (signed authorization response), signed introspection responses

Can I run FAPI 2.0 without DPoP? ​

Yes. Switch to mTLS sender binding by enabling it with op.WithFeature(feature.MTLS) and, when a proxy terminates TLS, configuring the certificate header with op.WithMTLSProxy(op.MTLSProxy{...}). FAPI 2.0 §5.3.2.1(4)–(5) requires "DPoP or mTLS". Clients still authenticate with private_key_jwt, or, with feature.MTLS enabled, with tls_client_auth or self_signed_tls_client_auth.

Tokens & rotation ​

My refresh-token retry returns invalid_grant — but the request was already in flight. ​

That's the rotation grace window of the profiles that rotate refresh tokens. The default is 60 seconds: if the rotation network round-trip dropped, presenting the previous refresh token within the window returns the saved successor without rotating again. A sender-constrained retry may receive a newly bound access token. After the window, or if the chain was already revoked for reuse detection, you get invalid_grant.

Tune the window

op.WithRefreshGracePeriod(90 * time.Second) widens it. op.WithRefreshGracePeriod(0) disables grace entirely (strict single-use); negative values are rejected at the option site. See Design judgments §2.

The two FAPI 2.0 profiles (profile.FAPI2Baseline, profile.FAPI2MessageSigning) do not rotate refresh tokens, as FAPI 2.0 Security Profile §5.3.2.1(9) requires, so there is no retry window to tune: op.New rejects op.WithRefreshGracePeriod under them. profile.FAPICIBA keeps rotating.

Why didn't I get a refresh token? ​

The built-in authorization-code response issues a refresh token only when all of these conditions hold:

  1. The provider's grant set accepts grant.RefreshToken (it is enabled by default; WithGrants replaces the default set).
  2. The client's GrantTypes includes refresh_token.
  3. The granted scope contains openid, unless op.WithOpenIDScopeOptional() is enabled.
  4. When op.WithStrictOfflineAccess() is enabled, the granted scope also contains offline_access.

If the provider or client grant is missing while openid is present, the token endpoint succeeds with access_token + id_token and no refresh_token. Without openid, no ID Token is issued. With op.WithOpenIDScopeOptional(), an OAuth-only request can receive an access token and a refresh token when the grant gates pass. Under this library's default policy, offline_access is not required to receive a refresh token — it selects the offline TTL bucket and shapes the consent/audit surface.

Making offline_access a hard requirement

OIDC Core 1.0 §11 defines offline_access for access after the user is no longer present. Opt in with op.WithStrictOfflineAccess() to make that scope a hard refresh-token issuance gate; every RP that wants this behavior must then request offline_access explicitly. The constructor rejects combining this option with op.WithOpenIDScopeOptional() as a library policy. See Design judgments §3.

Where do I split "stay signed in" from regular sessions? ​

op.WithRefreshTokenOfflineTTL(...) separates the long-lived offline_access chains from the conventional rotation TTL. The token.issued audit event carries extras.offline_access=true so SOC dashboards can split the chains.

What does revoking a refresh token revoke? ​

/revoke on a refresh token revokes that token's whole rotation chain, however long the chain has grown, and the access tokens issued under the grant stop being accepted. The user's other refresh chains on the same client are not touched, so a second device keeps working and can mint new access tokens. A refresh-token replay is the exception: RFC 9700 §2.2.2 treats it as theft, so the OP revokes the replayed chain together with every access token issued under the grant. The token.revoked audit event reports surface: "refresh_grant" when /revoke had to fall back from the chain to the grant's refresh tokens.

A bring-your-own store gets this per-chain scope only if it implements store.OpaqueAccessTokenChainRevoker and store.IssuanceRevocationChecker and persists ChainID. Without them, revocation stays grant-wide.

DPoP & sender-constraint ​

Why do I need a DPoP nonce, and how do I serve it? ​

Why. RFC 9449 §8 lets the OP push a server-supplied nonce through the DPoP-Nonce response header to mitigate pre-generated proof attacks.

How. The library ships an in-memory reference source and exposes the seam:

go
src, err := op.NewInMemoryDPoPNonceSource(ctx, rotate) // demo-grade
if err != nil { /* handle */ }
op.WithDPoPNonceSource(src)

See examples/51-dpop-nonce for the wiring shape.

Multi-instance deployments

A process-local nonce source breaks across replicas. For HA, use the Redis source from op/storeadapter/redis: redisStore.DPoPNonceSource(rotate) returns an op.DPoPNonceSource that every replica sharing the store's key prefix issues and validates against. See Multi-instance deployment.

dpop_signing_alg_values_supported — RS256 missing? ​

Correct. The DPoP discovery list is ES256, EdDSA, PS256 — narrower than the codebase JOSE allow-list, which the OP applies when it verifies a client assertion or JAR request object. DPoP proofs are restricted to the FAPI-recommended subset. Note that the OP signs with one algorithm: ES256 by default, or RS256 when you set op.WithSigningAlgorithm(op.AlgRS256). RS256 is refused together with the FAPI profiles.

Storage ​

Do I need to migrate my users table? ​

No. The library never reads or writes your users table directly and does not care about its column names. You implement a store.UserStore (a store.UserPasswordStore for password login) over the schema you already have and hand it to op.PrimaryPassword (or to op.WithUserStore when the flow has no password step or the claims live elsewhere). The OP asks "is this credential valid?" and "what claims does this subject have?" — that's the only contact surface.

The one column it may ask for is the subject: an opaque value assigned once per user and never reused. A table keyed only by an auto-increment id or an email address adds one; see Choosing the subject.

Which storage adapter should I pick? ​

AdapterWhen
inmemTests, demos, single-process dev
sql (SQLite / MySQL / Postgres)Single durable backend; easiest production path
redis (sessions, interactions, consumed JTIs)Pair with sql via composite for a hot/cold split; it cannot serve as the store alone
compositeHot/cold; all TxClusterKinds share one comparable transactional backend, while other kinds may route separately
dynamodbOne-table-per-substore durable backend; store.Transactional supports the browser authorization-code flow.

See /use-cases/sql-store, /use-cases/dynamodb-store, and /use-cases/hot-cold-redis.

Why does composite.New reject my config at boot? ​

Because the transactional cluster invariant says the substores that take part in one transaction must share one backend: authorization codes, refresh tokens, grants, pushed authorization requests, access tokens, opaque access tokens and grant revocations (composite.TxClusterKinds). Sessions and interactions are outside the cluster and may live in Redis. Consumed-JTI markers are not eviction-tolerant, so route them to Redis only on a noeviction instance; the default is to keep them with the durable store. composite.New checks all of this at construction and refuses a configuration that would split a transaction across two stores.

UI & SPA ​

go
op.WithUI(op.SPAUI{LoginMount: "/login", StaticDir: "./web/dist"})

op.SPAUI serves each prompt (auth.password, consent.scope, interaction.chooser, …) as JSON at LoginMount/state/{uid}, and the SPA shell at LoginMount/{uid} when StaticDir is set. The SPA — React, Vue, Svelte, Angular, or vanilla — fetches the prompt, POSTs {state_ref, values} back with the X-CSRF-Token header echoing prompt.csrf_token (double-submit cookie), and delivers the terminal {type:"redirect", location} or {type:"form_post", …} envelope. A rejected password comes back as the same prompt with error set, and labels arrive resolved in label_text. Do not use interaction.JSONDriver for a browser SPA: its terminal response is a plain 302 that fetch() cannot follow.

UI mount options

op.WithUI takes one mode. Use op.SPAUI when the OP should mount the SPA shell and JSON state surface for you. In that mode the shell lives at LoginMount/{uid} and prompt JSON at LoginMount/state/{uid}. Use op.HTMLUI with Consent or Chooser when you want server-rendered HTML templates for consent or account selection. op.WithLogoutUI restyles the two /end_session pages and applies in every mode. interaction.JSONDriver inside op.DriverUI (state endpoint /interaction/{uid}) remains for non-browser clients and custom drivers. See SPA / custom interaction, Custom chooser UI, examples/10-react-login, and examples/12-custom-chooser-ui.

SPA-safe error rendering

Error pages emit <div id="op-error" data-code="..." data-description="..."> under CSP default-src 'none'; style-src 'unsafe-inline', so the SPA host queries by selector without parsing markup.

CORS — how do I allow my SPA origin? ​

go
op.WithCORSOrigins("https://app.example.com")

For the API endpoints (/token, /userinfo, /introspect, /revoke, /par) the allowlist also includes the issuer origin and the redirect-URI origins of clients seeded with WithStaticClients. A client that lives only in the client store, or was created by dynamic registration, contributes no origin, so a browser client of that kind needs its origin in WithCORSOrigins. See /use-cases/cors-spa.

Redirect-URI origins never authorize cross-origin interaction or end-session ceremonies. Those accept only the issuer origin and origins explicitly passed to WithCORSOrigins, and a ceremony origin must be same-site with the issuer. A genuinely cross-site explicit origin may pass preflight and receive CORS headers, but the browser withholds the __Host- ceremony cookies, including __Host-oidc_interaction (SameSite=Lax), __Host-oidc_csrf (SameSite=Strict), and __Host-oidc_logout_csrf (SameSite=Strict), so interaction and end-session requests return 404. Put ceremony UI on an issuer-sibling host, such as login.example.com alongside op.example.com.

Yes. There are three supported paths:

  • Stay on the bundled HTML driver and rebrand via locale bundles. op.WithLocaleBundle overrides any consent string by key — the seed en / ja bundles cover the rendered surface and you ship overlays for the keys you care about. See Use case: i18n / locale negotiation.
  • Pass op.HTMLUI{Consent: ...} to op.WithUI and own the template. Consent is an op.PageTemplate whose Template is your *html/template.Template; the OP renders it with ConsentTemplateData and still owns state, CSRF, and persistence. See examples/11-custom-consent-ui.
  • Use op.SPAUI and own the markup. The consent prompt arrives as JSON at the state route, and your SPA renders it. See SPA / custom interaction.

Auth & MFA ​

Where does password / TOTP / passkey verification live? ​

The library ships building blocks (op.PrimaryPassword, op.StepTOTP, op.RuleAlways, …) that you compose into an op.LoginFlow. The flow decides which factors run and in which order; the credential storage is your implementation of store.UserPasswordStore and store.TOTPStore (the in-memory adapter exposes them as UserPasswords() and TOTPs()). The op.Authenticator type is exposed for fully custom factors (passkey, email-OTP, …). examples/20-mfa-totp and onward.

How do I implement step-up? ​

Add op.RuleACR("urn:example:mfa", op.StepTOTP{...}) to LoginFlow.Rules. It runs its step when the authorization request's acr_values contains that exact value; your flow defines which ACR values require which factors. This is an authorization-request rule, not the RFC 9470 resource-server-to-authorization-server challenge. examples/23-step-up shows the complete flow.

Can I trigger MFA only when the risk score is high? ​

Set LoginFlow.Risk to a RiskAssessor, then add a rule such as op.RuleRisk(op.RiskScoreHigh, op.StepTOTP{...}) to LoginFlow.Rules. RiskOutcome.Score selects the threshold; the rule's second argument is the step to run. See examples/21-risk-based-mfa.

Logout ​

Why no Front-Channel Logout? ​

Modern browser defaults (third-party-cookie phase-out, SameSite=Lax default) have made the iframe-based session signalling Front-Channel Logout 1.0 / Session Management 1.0 require effectively inoperative. The library ships RP-Initiated Logout 1.0 + Back-Channel Logout 1.0 instead — see Design judgments §5.

Back-Channel Logout fan-out is missing some RPs. ​

The /end_session handler snapshots each subject from the OP session before it calls the detached BCL coordinator. The coordinator derives its audience with GrantStore.ListClientIDsBySubject and resolves eligible clients with a registered back-channel URI. audit.BCLNoSessionsForSubject therefore means that this session-bearing logout notice resolved to zero eligible grant-derived RP targets; it is not a direct session-eviction signal. If a volatile SessionStore loses the session before the handler can take that snapshot, the logout trigger/snapshot can be lost and the fan-out (and this event) never starts. op.WithSessionDurabilityPosture records the declared durability so SOC dashboards can interpret that risk; see Design judgments §10.

Native apps & loopback ​

My CLI's 127.0.0.1:54312/cb redirect_uri got rejected. ​

This library's default redirect-URI matching is byte-exact, and its FAPI profiles keep that policy. Loopback port wildcarding (RFC 8252 §7.3) is opt-in per client: the registered redirect_uris list must contain a loopback URI, and the OP will then ignore port mismatch when scheme is http, the registered hostname is a loopback shape (127.0.0.1, ::1, or — when registration-side opt-in is on — the textual localhost), the requested host matches the registered one, and path / query / fragment exact-match. The textual localhost is admitted only when the embedder opted in at registration time (op.WithAllowLocalhostRedirectURIs() for web clients, application_type=native for native clients); deployments that need the strict literal-IP-only posture leave both opt-ins off. See Design judgments §4.

Observability ​

Where's /metrics? I configured op.WithPrometheus(...). ​

The library does not mount /metrics. op.WithPrometheus(reg) registers the OP's curated counter set on the registry you pass; the HTTP route is your router's job. The same separation applies to tracing (otelhttp.NewMiddleware is yours) and request-duration histograms. This is a deliberate scope decision — the OP emits OIDC business counters, logs, and audit events only; it does not create OpenTelemetry spans. HTTP-lifecycle observation is embedder territory.

examples/52-prometheus-metrics.

What audit events does the library emit? ​

A finite catalog (the audit.* constants of the op/audit package):

CategoryCovers
login.* / mfa.* / step_up.*login flow factor outcomes
code.* / token.* / refresh.*code + token issuance / refresh / revoke
session.* / logout.* / bcl.*session and logout lifecycle
consent.*consent decisions
dcr.*Dynamic Client Registration
device_authorization.* / device_code.*RFC 8628
ciba.*OIDC CIBA
token_exchange.*RFC 8693
client_authn.* / introspection.*client auth / introspection
account.* / federation.* / recovery.*account-management feed
rate_limit.* / pkce.* / redirect_uri.* / alg.* / cors.* / dpop.* / key.* / lockout.*defensive signals
custom_grant.* / grant_management.* / cascade.* / startup.*custom grants, Grant Management, teardown cascades, boot-time posture record

Every event carries audit="true" and its event identifier. Fields such as actor_id, client_id, and request_id appear only when set; the category-specific extras group is omitted when empty. Subscribe through op.WithAuditLogger(...) (a *slog.Logger). See the audit event catalog for the full record shape.

Conformance & versions ​

Why does the OFCS status show REVIEW as well as PASSED? ​

OFCS records several outcomes, and only PASSED means "ran clean". A raw FAILED means the suite did not observe the expected result, and it blocks the release unless the strict verifier matches it to a reviewed, unexpired exclusion:

VerdictWhat it means
PASSEDTest ran, OP behaved correctly per spec.
REVIEWTest ran, OP behaved correctly — OFCS wants a human to verify a UI artefact (e.g. a screenshot of the rendered error page).
FAILEDThe module did not reach the suite's expected result. It blocks a release unless it has a reviewed, expiry-bound exclusion.

The headless harness records REVIEW as-is rather than auto-passing it. The current release run and its detailed breakdown are documented on OFCS conformance.

Can I cite this library as "OIDF-certified"? ​

No. The project pays no OpenID Foundation membership and holds no formal certification. The OFCS baseline is a reproducible snapshot of spec conformance, not a certification. See Security posture.

Should I pin a tag? ​

Yes. Pin production dependencies in go.mod and read the CHANGELOG before an upgrade. Configurations using op.New with bundled storage adapters generally upgrade unchanged; BYO stores and other extensions need a review of the current migration guidance. APIs marked Experimental: remain the ordinary minor-release exemption.

Common errors ​

invalid_request: redirect_uri is not registered for this client ​

/auth compares redirect_uri byte for byte with the client's registered list, and shows this error on the OP's own page instead of redirecting, because the URI is not yet trusted. Three usual causes:

  1. Trailing slash drift (/cb vs /cb/).
  2. Default port included on one side (https://rp.example.com:443/cb vs https://rp.example.com/cb).
  3. CLI / native loopback without the RFC 8252 §7.3 opt-in (above).

invalid_request_object: request object alg is not allowed ​

The alg of a JAR request object at /auth or /par is outside the allow-list (RS256, PS256, ES256, EdDSA) or outside the client's request_object_signing_alg. A private_key_jwt client assertion with a bad alg gets the deliberately opaque invalid_client ("client authentication failed") instead. If you use this library's profile.FAPI2Baseline, narrow each client to PS256 (or ES256 / EdDSA); that library profile rejects RS256.

invalid_request: this OP requires authorization requests to be pushed via the PAR endpoint ​

A FAPI 2.0 profile makes PAR mandatory, so a plain /auth?... URL is refused. Push the request to /par first and send only client_id and the returned request_uri to /auth.

invalid_token: DPoP proof key does not match the bound thumbprint ​

The DPoP proof's public key thumbprint (RFC 7638) differs from the cnf.jkt bound to the access token. /userinfo answers with this invalid_token challenge, and a refresh with the wrong key gets invalid_grant. It is the sender binding doing its job: the proof was signed with a different key, or the token belongs to a different client.

/token answers 503 for a private_key_jwt client ​

The OP could not fetch or use the client's JWKS (jwks_uri) because the fetch was rejected for capacity or failed transiently. The response is 503 server_error ("client keys are temporarily unavailable"), not invalid_client, because the fault is on the OP side and the call is safe to retry. /auth, /par and /bc-authorize answer the same way for request objects. A client-store outage gives 500 server_error.

invalid_request_uri after a successful /par ​

You re-visited /oidc/auth?request_uri=… after the authorization code was already issued. request_uri is one-time at code emission (RFC 9126 §2.2; see Design judgments §1). Re-running /par mints a fresh URI.

Adoption ​

Should I use this in production? ​

It is a reasonable fit for internal-only deployments where you control the RP, the OP, and the users. It is not a fit if you need a paid audit trail or a formal certification to ship. Read Security posture — specifically the "What is not here" block — before you decide.

How do I report a security issue? ​

Privately, via GitHub Security Advisories. Full policy at Disclosure policy.