Skip to content

Why go-oidc-provider

Why this library exists

This library is a personal project. It comes out of years of standing up OIDC providers with OSS libraries in other languages: here the parts that should be one switch are one switch, and the parts that should never be on by default are not on.

Things that ought to be easy to embed — two-factor, passkeys, risk-based auth, SPA-driven flows, FAPI 2.0, i18n — are first-class building blocks. Deprecated, unsafe flows that other stacks keep "for compatibility" (Implicit, ROPC, alg=none) are not exposed as public options at all. The sections below walk through both halves; Use cases has production-shaped configurations.

You're writing a Go service. You need to be an OpenID Connect Provider — issuing ID tokens / access tokens, hosting the authorization and token endpoints, signing the discovery document. The choices on the market are:

ChoiceWhat you gainWhat you take on
1. Roll your own
(go-jose + a JWT lib)
Full control of the surfaceEvery CVE class is yours — algorithm confusion, redirect URI mismatch, PKCE downgrade, refresh-token reuse, cookie scope, CSRF on the consent post, …
2. Front a heavyweight IdPOperationally richThe IdP owns the user table, the templates, the upgrade cadence. Your Go service becomes an embedder of their product.
3. go-oidc-providerLibrary owns the protocolYou bring user accounts, storage, and UI

This page argues option 3 by walking through the things that hurt when you build options 1 or 2.

Who owns what
The split of responsibility. Your application owns users, screens and storage; the library owns the OIDC and OAuth ceremony; the consuming app only verifies tokens.you own thisthe library owns thissomeone else owns thisYour applicationusers · screens · storageAuthenticator · Store · Templatesgo-oidc-providerthe OIDC / OAuth ceremony/authorize · /token · /jwksRP / APIthe consuming applicationverifies the tokens it is given
The library never asks for your user table, your login screen, or your database. It asks for interfaces, which is the difference between a library and a product you have to migrate onto.

Where the line falls:

  • The library owns token issuance, signing, PKCE, CSRF, and the consent and logout ceremonies.
  • You own user lookup, password verification, screens, storage, and mounting the handler on your router.
  • Neither side can switch an unsafe legacy mode back on. Those modes are not exposed as options at all, and bundles such as FAPI are pinned by a profile.

Pain points, answered

"I want one switch for FAPI 2.0"

You can make PAR work, then later remember JAR, then later discover that discovery still advertises client_secret_basic, then later find that one test path signs an ID Token with a non-FAPI alg. That is the kind of drift FAPI makes expensive.

Background — what FAPI 2.0 demands

FAPI 2.0 Baseline mandates PAR (RFC 9126), JAR (RFC 9101), PKCE (RFC 7636), sender-constrained tokens via DPoP (RFC 9449) or mTLS (RFC 8705), ES256 OP signing, and redirect_uri exact match. Message Signing additionally requires JARM, a DPoP nonce, and RS-side response signing for non-repudiation. Toggling these by hand is a half-dozen options and three places the discovery document needs to agree.

op.WithProfile(profile.FAPI2Baseline) does the profile work — auto-enables PAR + JAR, selects DPoP unless mTLS is explicitly enabled, intersects token_endpoint_auth_methods_supported with the FAPI allow-list, and keeps OP-issued JWT signing on ES256.

go
op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
  op.WithLoginFlow(loginFlow),
  op.WithProfile(profile.FAPI2Baseline),
  op.WithDPoPNonceSource(nonces),
)

Conflicts caught at startup

The constructor refuses to start if the declared profile and the declared options conflict, so partial-FAPI never escapes review.

"I don't want to give up my users table"

Heavyweight IdPs often make user storage part of the product boundary: import users, sync them, accept their profile schema, then route login through their screens. That is a poor fit when the account model is already part of your Go service.

op.WithStore(s store.Store) plugs into a small set of substore interfaces (store.AuthorizationCodeStore, store.SessionStore, store.UserStore, …). The library never reads or writes your users table directly. When the claims should come from your existing table, pass an application-owned store.UserStore to op.WithUserStore(...); the protocol state stays in the adapter.

go
op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
  op.WithLoginFlow(passwordLoginFlow),
  op.WithStore(myStore),                 // protocol state
  op.WithUserStore(applicationUsers),    // claims from your users table
)

Reference adapters: inmem, sql (SQLite / MySQL / Postgres), redis (volatile substores), dynamodb (one table per substore; experimental), and composite (hot/cold splitter). See DynamoDB storage for its provisioning model.

"Cookies and CSRF on the consent POST are a minefield"

The risky part is not choosing a cookie library. It is remembering every browser rule that makes an OAuth session cookie hard to steal or replay, and applying those rules identically across login, consent, and logout.

Easy to get one detail wrong

The __Host- prefix, no Domain, Path=/, Secure, AES-256-GCM, double-submit CSRF, Origin / Referer check, the right SameSite — miss any one and you have a CVE class.

The library bakes in:

  • __Host- cookie prefix (no Domain, Path=/, Secure)
  • AES-256-GCM encryption (cookie key supplied via op.WithCookieKeys)
  • Double-submit CSRF + Origin / Referer check on the consent / logout POST
  • SameSite=Lax for the session cookie, Strict where compatible

You don't write any of this. You generate one 32-byte key, hand it to WithCookieKeys, and the cookie scheme is correct.

go
cookieKey := make([]byte, 32)
if _, err := rand.Read(cookieKey); err != nil {
  return err
}

op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
)

"I want to drive UI from a SPA"

The protocol engine should decide what prompt is next; your frontend should decide how it looks. Those are separate jobs.

op.WithSPAUI(op.SPAUI{...}) swaps the default HTML pages for a JSON-backed SPA flow, and lets the OP serve the shell and its static assets. Your SPA — React, Vue, Svelte, Angular — fetches each prompt from /interaction/{uid} and posts its response back. The protocol engine keeps the state machine.

go
op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
  op.WithLoginFlow(flow),
  op.WithSPAUI(op.SPAUI{
    LoginMount: "/login",
    StaticDir:  "../internal/webui/static",
  }),
)

UI mount options

op.WithSPAUI, op.WithConsentUI, and op.WithChooserUI are runnable integration points. Use WithSPAUI when the OP should mount a SPA shell, WithConsentUI for a server-rendered consent template, and WithChooserUI for a server-rendered account chooser. The lower-level interaction.JSONDriver remains available when you want to own the router and state fetch loop yourself. See examples/10-react-login.

SPA-safe error rendering

Error pages emit <div id="op-error" data-code="..." data-description="..."> so the SPA host can document.querySelector('#op-error') without parsing markup, under CSP default-src 'none'; style-src 'unsafe-inline'.

"I need real conformance, not 'we follow the RFC'"

Security reviews rarely fail because a team cannot cite an RFC. They fail because no one can show which optional branches were implemented, which were refused, and what the conformance suite actually exercised.

Each release is regressed against the OpenID Foundation conformance suite. The latest release run and its full raw breakdown, review rationale, and blocker decision are on OFCS conformance.

Reading REVIEW / SKIPPED

REVIEW is OFCS's "human reviewer must look" verdict — the OP error pages that stay there are intentional (details). SKIPPED are modules that exercise things the OP refuses by design (e.g. alg=none request objects). A raw FAILED or no-verdict result is never silently accepted: it must match a reviewed, unexpired exclusion for the release verifier to pass.

"I need observable refresh-token rotation"

When a mobile app retries the same refresh request, you want an idempotent replay inside the grace window. When a stolen old refresh token appears later, you want the whole chain revoked and an audit event that explains why.

Refresh tokens rotate by default. Reuse-detection invalidates the entire chain.

  • op.WithRefreshGracePeriod — widens the rotation window for racing clients; FAPI2Baseline and FAPI2MessageSigning accept explicit values from 0 through 60 seconds (inclusive) and reject only a value above 60 seconds.
  • Refresh rotation rechecks the effective scope against the client's current registration before consuming the presented token, so narrowing a registration takes effect on an existing chain.
  • op.WithRefreshTokenOfflineTTL — separates the lifetime of offline_access refresh tokens (stay-signed-in) from conventional rotation.

The token.issued / token.refreshed audit events carry an offline_access flag in extras so SOC dashboards can split the chains.

go
op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
  op.WithLoginFlow(loginFlow),
  op.WithRefreshGracePeriod(60*time.Second),
  op.WithRefreshTokenOfflineTTL(90*24*time.Hour),
  op.WithAuditLogger(auditLogger),
)

"I want metrics, but not a /metrics route I didn't ask for"

Libraries that mount their own observability routes tend to fight your router, auth boundary, path conventions, and SRE middleware. This library only emits protocol signals; you decide where those signals are served.

op.WithPrometheus(reg) registers a curated counter set on your registry. The library does not mount /metrics itself — that's your router's job.

The same separation holds for tracing (you bring otelhttp) and request duration histograms (your middleware).

go
reg := prometheus.NewRegistry()
provider, _ := op.New(
  /* required options */
  op.WithCookieKeys(cookieKey),
  op.WithLoginFlow(loginFlow),
  op.WithPrometheus(reg),
)

router.Handle("/oidc/", provider)
router.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))

What this library is not

Out of scope on purpose

  • Not an IdP. It does not store users, hash passwords, or send email. You bring the user model and an op.Authenticator. There's a TOTP authenticator shipped, but the password check is yours.
  • Not a generic OAuth2 framework. It targets OpenID Connect Core 1.0 and the FAPI 2.0 family. Pure-OAuth2 builds are supported via op.WithOpenIDScopeOptional, but the library is opinionated toward OIDC.
  • Not a UI kit. The default HTML driver exists so the OP boots without configuration; production embedders ship their own templates or a SPA.

Next

  • Concepts: OAuth 2.0 / OIDC primer — read this first if "client_credentials" or "authorization_code + PKCE" are unfamiliar.
  • Quick Start — get a minimal OP running in 30 lines of Go.
  • Use cases — production-shaped examples, each linked to a build-tagged file in examples/.