Skip to content

FAQ

This page is the bottom of every section's "where do I look first?" answer. The questions below are not theoretical — every entry maps to something the maintainer hit while building examples or running the conformance harness, so if you are stuck on the same thing, the answer here is the one we use.

Setup & basics

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

The four required options have no safe default, so op.New refuses to boot without them rather than silently using zero values:

OptionWithout it
WithIssuerOP can't sign / namespace anything.
WithStoreOP has nowhere to persist clients, codes, tokens.
WithKeysetOP can't sign ID tokens.
WithCookieKeysOP can't seal session / CSRF cookies.

The error names the missing piece, so a typo at boot is a build-time error, not a runtime mystery.

Recommended pattern

Generate a 32-byte cookie key with crypto/rand once per environment and feed it through your config / secret manager. See examples/01-minimal/main.go for the canonical 30-line setup.

Where does the OP mount? Can I prefix it?

Wherever you mount the returned http.Handler. The default mount prefix is /oidc; pass op.WithMountPrefix("/") to mount at the root or op.WithMountPrefix("/auth") to relocate it. The discovery document embeds the issuer + mount prefix you configured, so RPs see consistent URLs regardless of where you mount.

What's the smallest config that boots?

go
handler, err := op.New(
    op.WithIssuer("https://op.example.com"),
    op.WithStore(inmem.New()),
    op.WithKeyset(myKeyset),
    op.WithCookieKeys(cookieKey), // 32 bytes
)

Four options, no implicit defaults. See /getting-started/minimal.

"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 tightens six knobs the spec mandates:

KnobEffect
Feature flagsenables feature.PAR and feature.JAR; selects feature.DPoP unless feature.MTLS is explicitly enabled
Client authnarrows token_endpoint_auth_methods_supported to the FAPI allow-list (private_key_jwt, tls_client_auth, self_signed_tls_client_auth)
Alg constraintslocks signature algorithms to the FAPI subset
redirect_urienforces exact match (no wildcarding)
PKCErequired on every code request
state / nonceone of the two required on every authorize request

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

The profile is intentionally rigid — silently relaxing it would break the audit guarantee FAPI 2.0 buys you.

Baseline vs Message Signing — when do I need each?

BaselineMessage Signing
PAR + JAR + PKCE + DPoP / mTLS+ JARM (signed authorization response)

If your RP needs non-repudiation of the authorization request / response — e.g. open-banking style audit chain — pick Message Signing. Otherwise Baseline is enough.

Can I run FAPI 2.0 without DPoP?

Yes — switch to mTLS sender-binding by enabling feature.MTLS and configuring the FAPI client with tls_client_auth / self_signed_tls_client_auth. FAPI 2.0 §3.1.4 mandates "DPoP or mTLS"; the library honours either.

Tokens & rotation

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

That's the rotation grace window. The default is 60 seconds: if the rotation network round-trip dropped, presenting the previous refresh token within the window mints a fresh access token without rotating again. 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.

Why didn't I get a refresh token?

Three conditions, all required:

  1. The granted scope contains openid.
  2. The granted scope contains offline_access.
  3. The client's GrantTypes includes refresh_token.

Drop any one and the token endpoint succeeds with access_token + id_token and no refresh_token.

Why strict by default?

OIDC Core 1.0 §11 leaves room for OPs to issue refresh tokens without offline_access, but doing so makes the consent prompt and the audit trail disagree on what the user authorised. The library reads the strict interpretation so both agree out of the box. 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.

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, plug a shared store (Redis) behind your DPoPNonceSource. The library deliberately doesn't ship a Redis nonce source yet — the option matrix (TTL, rotation cadence, missed-rotation tolerance) is too specific to operator setup.

dpop_signing_alg_values_supported — RS256 missing?

Correct. The DPoP discovery list is ES256, EdDSA, PS256 — narrower than the codebase JOSE allow-list. RS256 works for ID-token signing where appropriate, but DPoP proofs are restricted to the FAPI-recommended subset.

Storage

Do I need to migrate my users table?

No. The library never reads or writes your users table directly. You implement op.Authenticator (or use the supplied TOTP one) and store.UserStore against whatever schema you already have. The OP asks "is this credential valid?" and "what claims does this subject have?" — that's the only contact surface.

Which storage adapter should I pick?

AdapterWhen
inmemTests, demos, single-process dev
sql (SQLite / MySQL / Postgres)Single durable backend; easiest production path
redis (volatile substores only)Pair with sql via composite for hot/cold split
compositeHot/cold; the store enforces "one durable backend" at construction time
dynamodbPlanned (v1.x)

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

Why does composite.New reject my config at boot?

Because the transactional cluster invariant says all transactional substores (clients / codes / refresh tokens / access tokens / IATs) must share one backend. The volatile slice (sessions / DPoP nonce cache / JAR jti registry) is the only part that may diverge. composite.New validates this at construction and refuses to boot a configuration that would split a transaction across two stores.

UI & SPA

go
import "github.com/libraz/go-oidc-provider/op/interaction"

op.WithInteractionDriver(interaction.JSONDriver{})

The JSON driver returns each prompt (login, consent.scope, chooser, …) as JSON at the same /interaction/{uid} path the HTML driver uses. 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 follows the terminal {type:"redirect", location} envelope.

UI mount options

Use op.WithSPAUI 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.WithConsentUI and op.WithChooserUI when you want server-rendered HTML templates for consent or account selection. The interaction.JSONDriver path remains available when your own router serves the shell; its state endpoint is /interaction/{uid}. See SPA / custom interaction, Custom chooser UI, examples/10-react-login, and examples/12-custom-chooser-ui.

WithSPAUI and WithConsentUI are mutually exclusive. WithChooserUI can be supplied with WithSPAUI, but SPA mode shadows the chooser template and emits a warning because the SPA owns the chooser surface.

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")

The library auto-derives the allowlist from registered redirect URIs when you don't pass one. See /use-cases/cors-spa.

Yes. There are three supported paths:

  • Stay on the bundled HTML driver and rebrand via locale bundles. op.WithLocale 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.WithConsentUI and own the template. The OP renders your *html/template.Template with ConsentTemplateData and still owns state, CSRF, and persistence. See examples/11-custom-consent-ui.
  • Switch to the JSON driver and own the markup. op.WithInteractionDriver(interaction.JSONDriver{}) returns the consent prompt as JSON, and your own page (or 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 is the DAG deciding which factors run in which order; the credential storage is your store.UserPasswords() / store.TOTPs() implementation. 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?

op.RuleACR(level) on the per-client policy. When the RP requests a higher acr_values than the current session provides, the OP returns WWW-Authenticate: error="insufficient_user_authentication" (RFC 9470). The session steps up through your authenticator chain and resumes. examples/23-step-up.

Risk-based MFA?

op.RuleRisk(...) consumes a RiskAssessor you supply. RiskOutcome carries an explicit RiskScore. 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.

This is expected when sessions live in a volatile store. If a session record was evicted before the logout fan-out runs (e.g. Redis TTL expired during a network partition), the library cannot fabricate it back. The op.AuditBCLNoSessionsForSubject audit event records the gap and carries the configured op.SessionDurabilityPosture, so SOC dashboards can distinguish "expected gap under volatile placement" from "unexpected gap under durable placement". This is best-effort by design — see Design judgments §10.

Native apps & loopback

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

Default redirect-URI matching is byte-exact (OAuth 2.1 / FAPI 2.0). 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.WithAllowLocalhostLoopback() 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. Same separation for tracing (otelhttp.NewMiddleware is yours) and request duration histograms. This is a deliberate scope decision — the OP emits OIDC business counters / spans / audit events only; HTTP-lifecycle observation is embedder territory.

examples/52-prometheus-metrics.

What audit events does the library emit?

A finite catalog (search op.Audit* constants in op/audit.go):

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.*defensive signals

Every event carries request-id, subject, client-id, plus an extras map for category-specific fields. Subscribe via op.WithAuditLogger(...) (a *slog.Logger) — each event records as a structured log entry with the catalog name and extras attributes.

Conformance & versions

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

OFCS uses three verdicts; only FAILED is an OP defect:

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).
FAILEDOP behaved wrongly.

The headless harness records REVIEW as-is rather than auto-passing it. The current four-plan baseline has zero FAILED. Full breakdown at 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.

Pre-v1.0 — should I pin a tag?

Yes. The public Go API may carry breaking changes in any minor release until v1.0. Pin in go.mod and read the CHANGELOG on every bump. The library guarantees that BREAKING entries are called out explicitly.

Common errors

invalid_request: redirect_uri does not match a registered URI

You hit redirect-URI exact-match. 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_client: alg not allowed

The client's request_object_signing_alg / token_endpoint_auth_signing_alg is not in the codebase allow-list (RS256, PS256, ES256, EdDSA). For FAPI 2.0 plans, narrow each client to PS256 (or ES256 / EdDSA); FAPI 2.0 forbids RS256 per spec.

invalid_dpop_proof: jkt mismatch

The DPoP proof's public key thumbprint (RFC 7638) doesn't match the cnf.jkt bound to the access token. This is the sender-binding working — either the proof was generated with a different key, or the access token belongs to a different client.

invalid_request_uri after a successful /par

You re-visited /authorize?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?

Read Security posture — specifically the "What is not here" block — and decide. Short version: fine for internal-only deployments where you control the RP / OP / users; not a fit if you need a paid audit trail or formal certification to ship.

How do I report a security issue?

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