Security posture
This page lays out how the library tries to stay safe, and — equally important — what kind of assurance it does and does not give you.
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:
| # | Property | Evidence here |
|---|---|---|
| 1 | Conformance — implements the spec | OFCS passing (compliance/ofcs) |
| 2 | Correctness — sane on all inputs | Unit + scenario + fuzz tests under test/ |
| 3 | Security — resists active attackers | Structural defenses (below) + design judgments |
| 4 | Supply-chain — dependencies stay safe | govulncheck in CI, depguard, third-party manifest |
| 5 | Operational — stays safe over time | SECURITY.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.
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.
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: WithKeyset accepts only ECDSA P-256 keys and discovery advertises ES256. Algorithm-confusion attacks (RFC 7519 §6 / RFC 8725 §2.1) are structurally unreachable.
| Concern | Mitigation |
|---|---|
alg=none accepted by a JWT lib | Algorithm(s) rejects unknown / empty values |
HS256 with public-key trust path | HS* not in the type at all |
| Per-deployment alg "feature flag" creep | depguard 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 op/storeadapter/redis — a sub-module that cannot import internal/timex; embedders override its fallback with WithClock. 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/, op/profile/, op/feature/, op/grant/, 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
| Defence | Source of truth | Spec |
|---|---|---|
__Host- cookies, AES-256-GCM, double-submit CSRF, Origin/Referer check | internal/cookie, internal/csrf | OWASP ASVS L1 / RFC 6265bis |
PKCE required for public / native authorization-code clients and for every authorization-code client under FAPI profiles; plain refused | internal/authorize, internal/pkce | RFC 7636, RFC 9700 |
| Refresh-token rotation + reuse detection (chain revoke) | internal/grants/refresh | RFC 9700 §4.14 |
Sender-constrained tokens (DPoP cnf.jkt, mTLS cnf.x5t#S256) | internal/dpop, internal/mtls, internal/tokens | RFC 9449, RFC 8705 |
iss in authorization response | internal/authorize | RFC 9207 |
redirect_uri exact match (configurable; default exact) | internal/authorize | OAuth 2.1, RFC 8252 |
| Loopback redirect hardening | internal/registrationendpoint, internal/authorize | RFC 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.Transport | internal/securefetch, internal/netsec | OWASP SSRF, OIDC Back-Channel Logout 1.0 |
OP-side request_object replay (jti) | internal/jar | RFC 9101 §10.8 |
| Algorithm allow-list at every verify path | internal/jose | RFC 8725 |
| Issuer canonicalisation / no trailing slash drift | op/options_validate.go | RFC 9207 |
Resource indicator canonicalisation (lowercase scheme + host, default port stripped, trailing slash normalised, fragment / userinfo refused) on /authorize, /token, /device_authorization, /bc-authorize, and the WithAccessTokenFormatPerAudience allow-list | internal/resourceindicator | RFC 8707 §2 |
Duplicate single-valued parameters refused on /token, /bc-authorize, /end_session (RFC 8707 resource= excepted); credentials presented in more than one location refused at clientauth.Parse | internal/httpx, internal/clientauth, internal/tokenendpoint, internal/cibaendpoint, internal/endsession | RFC 6749 §3.2.1, OIDC RP-Initiated Logout 1.0 §3 |
Argon2id parameters above the OWASP 2024 floor (memory ≥ 19 MiB, time ≥ 2) for client_secret, end-user passwords, and recovery codes; recovery-code batch verification capped at 16; duplicate parameter segments in encoded hashes refused | internal/argon2id, internal/authn/password, internal/authn/recovery, internal/clientauth/secret | OWASP Password Storage Cheat Sheet (2024) |
| Trailing JSON documents refused on the DCR metadata decoder and the interaction JSON driver | internal/registrationendpoint, op/interaction | RFC 7591 §2 |
op.New rejects configurations whose enabled grants / features need a substore the wired store does not expose | op/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 alg | internal/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 kid; random unknown kids cannot amplify outbound fetches | internal/clientauth, internal/jar | RFC 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 request | internal/clientauth | RFC 7515 §4.1.4, RFC 7523 |
| JWKS parsing rejects private and symmetric keys before verification paths see them | internal/clientauth, internal/jar, internal/jose | RFC 8725 |
DPoP-bound access tokens presented under the Bearer scheme are rejected at /userinfo; sender-constrained tokens must use the DPoP scheme | internal/endpointsupport, internal/userinfo | RFC 9449 §7.1 |
| Tampered or undecodable authorization-endpoint session cookies are cleared and treated as no session, instead of being carried forward | internal/authorizeendpoint, internal/cookie | RFC 6265bis |
One-time auth factors (email-OTP, TOTP, recovery codes) single-use via atomic compare-and-set (ErrAlreadyConsumed on replay); cross-factor lockout stamped atomically (StampLock) | 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 attempts | internal/authn | — (defence in depth) |
Refresh-token id / parent_id hashed at rest, looked up in constant time | internal/tokenendpoint, op/store | RFC 9700 §2.2.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 save | op/storeadapter/sql, op/storeadapter/inmem | RFC 9700 §2.2.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 audience | internal/customgrant/tokenexchange | RFC 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 behind | internal/tokenendpoint, op/store | — (defence in depth) |
Tooling
| Tier | Tool | Where |
|---|---|---|
| Lint | golangci-lint v2 (errcheck, govet, staticcheck, unused, gosec, errorlint, revive, depguard, …) | .golangci.yml |
| Vuln scan | govulncheck | scripts/govulncheck.sh |
| Fuzz | Go 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 |
| License | go-licenses | scripts/licenses.sh |
| Conformance | make conformance-baseline | tools/conformance/, conformance/ |
| Scenarios | catalog-driven Spec Scenario Suite | test/scenarios/ |
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.
- ❌ No CVE database entry yet (see Disclosure policy). No report meeting the CVE criteria has been received, so there is nothing to publish; that's the literal status, not "we don't bother".
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 control | Reasonable 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 reasons | Don't — until there is a paid audit you can cite |
| Learning OIDC mechanics from a real OP codebase | Best use; the structural defenses are part of the lesson |
Read next
- Design judgments — how the library resolves conflicts between RFCs (PAR vs
request_urireuse, refresh rotation vs RFC 9700 grace, alg list vs OIDC Core, …). - Disclosure policy — vulnerability reporting, supported versions, CVE handling.
- OFCS conformance — what conformance does and does not prove.