Skip to content

Security posture ​

This page covers two things: how the library tries to stay safe, and what kind of assurance it does and does not give you. "OP" means OpenID Provider, the authorization server this library implements; "OFCS" is the OpenID Foundation conformance suite.

Set expectations

go-oidc-provider is an OSS library maintained by a single individual developer in their spare time. It has not been independently audited, not received a paid pentest, and not been formally certified by the OpenID Foundation. The properties below describe what the codebase actively defends against; they are not equivalent to a third-party audit report.

Five separate properties ​

When someone asks "is this safe?", the honest answer needs five sub-answers:

#PropertyEvidence here
1Conformance — implements the specOFCS passing (compliance/ofcs)
2Correctness — sane on all inputsUnit + scenario + fuzz tests under test/
3Security — resists active attackersStructural defenses (below) + design judgments
4Supply-chain — dependencies stay safegovulncheck in CI, depguard, third-party manifest
5Operational — stays safe over timeSECURITY.md disclosure path, GitHub Security Advisories

Conformance ≠ Security. An OFCS-passing OP can still be exploitable (alg confusion, PKCE downgrade, redirect_uri partial match, timing attacks on secret compare). The next sections describe how each class is closed structurally rather than by runtime checks alone.

Five separate properties behind "is this safe?"
Conformance, correctness, security, supply chain and operational assurance are five separate properties. Each is evidenced differently, and a claim about one says nothing about the others.1ConformanceOFCSrun against the suite2Correctnesstests · fuzzingdoes it do what it says3Securitystructural defensesnot a list of checks4Supply chaingovulncheck · depguardenforced in CI5OperationalSECURITY.md · GHSAwhat happens on a report
They are listed in the order they should be read, not in order of importance. A passing conformance run says nothing about the fourth row, and a clean dependency scan says nothing about the third.

Structural defenses (what the codebase enforces by shape) ​

These are not "we remembered to validate"; they are decisions made at the type / package boundary so the unsafe path does not exist.

1. The constructor refuses zero-value boots ​

op.New(...) returns error — not a "use defaults" handler — when a required option is missing. WithIssuer, WithStore, and WithKeyset are unconditionally required; WithCookieKeys is required whenever the authorization_code grant is enabled, including the default grant set. There is no usable zero-value Provider.

Construction also refuses settings that would otherwise be ignored or fail on first use:

  • a LoginFlow.Primary that needs an already-bound subject (PrimaryPasskey, StepTOTP, StepEmailOTP, StepRecoveryCode belong after a subject-binding primary, as Rule steps);
  • a statically seeded confidential client with no secret under client_secret_basic or client_secret_post, which would otherwise accept an empty client_secret, or with a plaintext Secret shorter than 32 bytes (the OIDC Core §16.19 minimum for a secret used as an HS256 key; op.NewClientSecret mints one);
  • a login flow with a password or second-factor step and no resolvable lockout store, so per-subject brute-force limiting cannot be silently off;
  • browser sign-in options (WithLoginFlow, WithUI, WithInteractions, WithFirstPartyClients) combined with a WithGrants set that leaves out authorization_code;
  • an acr_values_supported entry that no login can produce, WithMTLSProxy or WithMTLSRootCAs without feature.MTLS, and WithAllowInsecureOutboundForDev against an issuer that is not a development host;
  • a typed-nil substore, a second WithGrantManagement, and a WithInteractions entry named consent or chooser (reserved for the built-ins);
  • a negative CIBA expiry or poll interval, and under FAPI-CIBA an expiry above 10 minutes.
Why this matters

Most "default-on" framework libraries silently boot on missing config. This shape closes the class of "OP came up with no signing key / guessable cookie key / wrong issuer" errors before a single request is served. See op.WithIssuer / op.WithKeyset / op.WithCookieKeys / op.WithStore for the build-time errors emitted on absence.

2. The JOSE alg list is a closed type ​

internal/jose.Algorithm enumerates only RS256, PS256, ES256, EdDSA. none, HS256/384/512, and any custom string fail IsAllowed(). ParseAlgorithm returns ok=false rather than a fallback. Every incoming JWS verification path — including the packages that hold raw go-jose handles (internal/jar, internal/dpop, internal/mtls, internal/backchannel, …) — gates the inbound alg through this closed type. OP-issued JWT signing is narrower still: one algorithm per OP, ES256 by default or RS256 through WithSigningAlgorithm, and discovery advertises only that one. Algorithm-confusion attacks (RFC 7519 §6 / RFC 8725 §2.1) are structurally unreachable.

ConcernMitigation
alg=none accepted by a JWT libAlgorithm(s) rejects unknown / empty values
HS256 with public-key trust pathHS* not in the type at all
Per-deployment alg "feature flag" creepdepguard jose-isolation rule pins the set of packages allowed to import go-jose/v4 directly; a new caller has to be added to the allow-list intentionally

3. crypto/rand only — never math/rand ​

A repository-wide rule bans math/rand. The lint configuration backs it up. Every nonce, code, refresh token, authorization code, and DPoP server-nonce uses crypto/rand.

4. time.Now() is funnelled through internal/timex/ ​

Direct time.Now() calls are blocked by the forbidigo lint, so every wall-clock read flows through internal/timex.Clock. The single canonical site is internal/timex.systemClock.Now(), with one documented carve-out in each of the separately versioned op/storeadapter/redis, sql and dynamodb modules, which cannot import internal/timex; embedders override a module's fallback with its WithClock option. A Clock abstraction makes time injection at the boundary explicit and prevents replay-window tests from passing accidentally because clocks drift differently in tests.

5. Errors go through a typed catalog ​

fmt.Errorf cannot create an API-visible error. Every wire-emitted error is built from op.Error / catalog values, so error codes, status codes, and WWW-Authenticate shapes stay consistent — and information leakage is bounded by the catalog rather than free-form formatting.

6. The internal/ boundary is non-negotiable ​

External code cannot import internal/. Anything that needs to be public has a stable public surface in op/ or one of its subpackages (op/profile/, op/feature/, op/grant/, op/audit/, op/authn/, op/store/, op/storeadapter/, ...). Embedders who want to "just patch the JAR verifier" cannot, by Go's package visibility rules.

7. ORM-agnostic by design ​

The library never embeds an ORM (no GORM, no ent, no xo). Storage is through small store.*Store interfaces. Embedders implement them with whatever they already use. Side effect: there is no "surprise migration" — the library cannot mutate your DB behind your back.

Defensive features that ship enabled ​

DefenceSource of truthSpec
__Host- cookies, AES-256-GCM, double-submit CSRF, Origin/Referer checkinternal/cookie, internal/csrfOWASP ASVS L1 / RFC 6265bis
PKCE required for public / native authorization-code clients and for every authorization-code client under the OAuth21 and FAPI 2.0 profiles; plain refusedinternal/authorize, internal/pkceRFC 7636, RFC 9700
Refresh-token rotation + reuse detection: a replay retires the chain and the grant's access tokens, while a rotation or /revoke stays scoped to its own chain so the user's other sessions on the same client are untouchedinternal/grants/refresh, internal/grants/teardownRFC 9700 §4.14.2
Sender-constrained tokens (DPoP cnf.jkt, mTLS cnf.x5t#S256)internal/dpop, internal/mtls, internal/tokensRFC 9449, RFC 8705
iss in authorization responseinternal/authorizeRFC 9207
redirect_uri exact match (configurable; default exact)internal/authorizeOAuth 2.1, RFC 8252
Loopback redirect hardeninginternal/registrationendpoint, internal/authorizeRFC 8252
Outbound HTTP envelope (allowed schemes, body cap, accept content-types, timeout, redirect, cache) on JAR JWKS / client-encryption JWKS / sector_identifier_uri / back-channel logout — dial-time deny-list (loopback / link-local / RFC 1918 / IPv6 ULA), URL-gate fallback when BaseTransport is not *http.Transportinternal/securefetch, internal/netsecOWASP SSRF, OIDC Back-Channel Logout 1.0
OP-side request_object replay (jti)internal/jarRFC 9101 §10.8
Algorithm allow-list at every verify pathinternal/joseRFC 8725
Issuer canonicalisation / no trailing slash driftop/options_validate.goRFC 9207
Resource indicator canonicalisation (lowercase scheme + host, default port stripped, trailing slash normalised, fragment / userinfo refused) on /auth, /token, /device_authorization, /bc-authorize, and the WithAccessTokenFormatPerAudience allow-listinternal/resourceindicatorRFC 8707 §2
Duplicate single-valued parameters refused on /token, /bc-authorize, /end_session (RFC 8707 resource= excepted)internal/httpx, internal/clientauth, internal/tokenendpoint, internal/cibaendpoint, internal/endsessionRFC 6749 §3.2.1, OIDC RP-Initiated Logout 1.0 §3
Argon2id parameters above the OWASP Password Storage Cheat Sheet floor (memory ≥ 19 MiB, time ≥ 2) for client_secret, end-user passwords, and recovery codesinternal/argon2id, internal/authn/password, internal/authn/recovery, internal/clientauth/secretOWASP Password Storage Cheat Sheet
Data after the first JSON value (a stray }) refused on every JSON-reading endpoint and driverinternal/httpx, internal/registrationendpoint, op/interactionRFC 7591 §2
op.New rejects configurations whose enabled grants / features need a substore the wired store does not exposeop/options_validate.go, op/storeadapter/redis— (defence in depth)
Client verification keys (client_assertion, JAR request objects) held to the OP key-shape floor: RSA ≥ 2048 bits, EC curve matches the declared alginternal/clientauth, internal/jar, internal/jose (AssertAlgKeyShape)RFC 7518 §3.3, RFC 8725 §3.2
private_key_jwt clients using jwks_uri recover from legitimate RP key rotation by doing one throttled cache-bypassing refetch on unknown kidinternal/clientauth, internal/jarRFC 7523, OIDC Dynamic Registration
private_key_jwt assertion verification selects by JWS kid, caps trial verifications even when kid is absent, equalises the no-keys rejection path, and memoizes client lookup per requestinternal/clientauthRFC 7515 §4.1.4, RFC 7523
JWKS parsing rejects private and symmetric keys before verification paths see theminternal/clientauth, internal/jar, internal/joseRFC 8725
A client record carrying both jwks and jwks_uri is refused on every key-reading surface (request objects, private_key_jwt, response encryption)internal/clientauth, internal/jar, internal/clientencjwksRFC 7591 §2
DPoP-bound access tokens presented under the Bearer scheme are rejected at /userinfo; sender-constrained tokens must use the DPoP schemeinternal/endpointsupport, internal/userinfoRFC 9449 §7.1
Tampered or undecodable authorization-endpoint session cookies are cleared and treated as no session, instead of being carried forwardinternal/authorizeendpoint, internal/cookieRFC 6265bis
One-time auth factors (email-OTP, TOTP, recovery codes) single-use via atomic compare-and-set (ErrAlreadyConsumed on replay)internal/authn, op/store— (defence in depth)
Wrong-code branches for TOTP, email-OTP, and recovery codes all increment the shared retry path and observer, so captcha / lockout gates see second-factor brute-force attemptsinternal/authn— (defence in depth)
Refresh-token id / parent_id hashed at rest, looked up in constant timeinternal/tokenendpoint, op/storeRFC 9700 §4.14.2
Refresh-token rotation re-checks the parent under the same critical section / transaction as the child insert, so replay revocation cannot race a successful descendant saveop/storeadapter/sql, op/storeadapter/inmemRFC 9700 §4.14.2
Token exchange re-verifies the post-policy decision: granted scope must remain within the requested subject-token-bounded scope, and granted audience must remain within the requested audienceinternal/customgrant/tokenexchangeRFC 8693
Persisted opaque access tokens minted during a token response are revoked on post-mint server_error paths, so a failed response does not leave a valid orphan token behindinternal/tokenendpoint, op/store— (defence in depth)
An authorization code presented by the wrong client, or failing the redirect_uri or PKCE check, is refused without consuming the code or tearing down the legitimate holder's grantinternal/tokenendpointRFC 6749 §4.1.2, RFC 9700
Each code, refresh chain and access token carries the auth_time, acr, amr, claims request and authorization_details of the authorization that issued it, not those of the shared grantop/store, internal/tokenendpointOIDC Core 1.0 §2, RFC 9396 (see #32)
LoginFlow rules and the Decider also run on authorizations served from an existing session, and fail closed on a panic or a malformed verdictinternal/authorizeendpoint, opRFC 9470 (see #33)
id_token_hint accepts only typ absent or JWT, so a logout token or access token cannot stand in for an ID token at /end_session or for CIBAinternal/idtokenhintRFC 8725 §3.11
Password login takes the same time for an account whose stored hash is below the Argon2id policy, or in another encoding, as for any other failed logininternal/authn/passwordCWE-208
A client in WithFirstPartyClients skips the consent screen only when the client record resolved for the request is static, admin-managed or legacy (never a dynamically registered or metadata-document record)internal/authorizeendpointOIDC Core 1.0 §3.1.2.4
An account picked at prompt=select_account goes through the same LoginFlow rules, Decider and risk step-up as a returning sessioninternal/authorizeendpoint, internal/authnRFC 9470
The ID Token carries only an acr the authentication satisfied: op.DefaultACRPolicy satisfies the canonical AAL URIs at or below the level reached, and an unmet voluntary request reports the level actually reachedop/acr.goOIDC Core 1.0 §2, RFC 9470
Passkey user verification is read from the flags of each assertion, not copied from the stored credential, so a presence-only assertion does not reach AAL2internal/authnWebAuthn Level 2 §6.1
A grant revoked while a code exchange, refresh, token exchange, or device-code or CIBA poll is in flight is re-checked after the token is persisted, and the credential just issued is revokedinternal/tokenendpointRFC 9700
Signed /userinfo responses always carry the OP's iss and the client's aud, and a claim source cannot supply iss, aud or another reserved JWT claiminternal/userinfo, internal/userclaimsOIDC Core 1.0 §5.1, §5.3.2
A DPoP proof longer than 8 KiB is rejected as malformed before its embedded jwk is parsed; a client JWK Set member that decodes but is not a valid key is ignored instead of panickinginternal/dpop, internal/joseRFC 9449 §4.3, RFC 7517
/end_session with a matching id_token_hint asks for confirmation when the logout would also end other accounts signed in on the same browserinternal/endsessionOIDC RP-Initiated Logout 1.0 §2
  • Outbound HTTP envelope: A fetch through a proxy is URL-checked against the target host first, the proxy address must itself pass the deny-list, and environment proxy variables are not honoured
  • Duplicate single-valued parameters refused on /token: Credentials presented in more than one location refused at clientauth.Parse
  • Argon2id parameters above the OWASP Password…: Recovery-code batch verification capped at 16; duplicate parameter segments in encoded hashes refused
  • private_key_jwt clients using jwks_uri…: Random unknown kids cannot amplify outbound fetches
  • A client record carrying both jwks and…: A jwks_uri used by several surfaces is fetched and cached once
  • One-time auth factors: Cross-factor lockout state advanced by compare-and-swap (AuthnLockoutStore.CompareAndSwap)
  • An authorization code presented by the wrong client: Only a genuine replay does
  • A client in WithFirstPartyClients skips…: prompt=consent, offline_access, an untrusted Sec-Fetch-Site navigation, prompt=create and the account chooser still ask
  • An account picked at prompt=select_account…: A login that continues an existing session is bound to that session's subject, so a rule step cannot sign a different account in
  • Signed /userinfo responses always carry…: A standard claim whose JSON type differs from OIDC Core §5.1 is left out with one warning per claim name

Tooling ​

TierToolWhere
Lintgolangci-lint (errcheck, govet, staticcheck, unused, gosec, errorlint, revive, depguard, …).golangci.yml
Vuln scangovulncheckscripts/govulncheck.sh
FuzzGo native Fuzz* (run make fuzz or scripts/fuzz.sh 30s)targets across internal/jose, internal/dpop, internal/pkce, internal/clientauth, internal/parendpoint, internal/introspectendpoint, internal/revokeendpoint, internal/jarm, internal/mtls, internal/authorizationdetails
Licensego-licensesscripts/licenses.sh
Conformancemake conformance-baselinetools/conformance/, conformance/
Scenarioscatalog-driven Spec Scenario Suitetest/scenarios/

The Go language baseline and toolchain are declared by the core module; review the core go.mod rather than relying on a release-specific value copied into this page.

What is not here ​

Be loud about the limits. This is the part you should read twice.

Read this before adopting

  • ❌ No paid third-party security audit.
  • ❌ No paid pentest.
  • ❌ No formal OpenID Foundation certification. (See OFCS conformance.)
  • ❌ No SLA on patch turnaround. SECURITY.md commits to aim — 3 business days ack, 14 days for confirmed-issue mitigations — without a contractual guarantee.
  • ❌ No 24/7 incident channel. Disclosure is via GitHub Security Advisories or the maintainer profile.
  • ❌ This page is not a live CVE ledger. Advisory status can change; use the Disclosure policy and the repository's GitHub Security Advisories for the current record.

If you need any of those for compliance, this library is the wrong choice until it has a formal audit. The honest framing costs nothing and prevents misuse.

How to think about adoption ​

If you are…Then…
Building an internal-only OP for a product you controlReasonable fit; pin to a tag, run govulncheck in your own CI, follow GHSA
Building a public-facing OP for high-value flows (banking-grade FAPI)Treat the library as a starting point, run a third-party audit, contribute findings back
Replacing a certified IdP for compliance reasonsDon't — until there is a paid audit you can cite
Learning OIDC mechanics from a real OP codebaseBest use; the structural defenses are part of the lesson
  • Design judgments — how the library handles protocol choices (PAR request_uri reuse, its own refresh retry grace, algorithm policy alongside OIDC Core, …).
  • Disclosure policy — vulnerability reporting, supported versions, CVE handling.
  • OFCS conformance — what conformance does and does not prove.