Skip to content

Required options ​

op.New(...) rejects partial configurations at construction time, so an unsafe OP cannot take traffic by accident. When several required options are missing it reports all of them in one error, and errors.Is still matches each one's sentinel. Three options are always required. Two more join them whenever the authorization_code grant is enabled, and the default grant set enables it: WithCookieKeys and WithLoginFlow.

Three always, and two with the code grant
Three options are always required: the issuer, the store, and the keyset. Cookie keys and a login flow become required in addition as soon as the authorization_code grant is enabled, because that grant puts a user and a browser in the loop.always requiredop.WithIssueriss claim on every tokendiscovery and endpoint URLsop.WithStorecodes · sessions · clientsrefresh chains · JTI setsop.WithKeysetsigning keys: ID tokens, JARMand JWT access tokensand these two, once the authorization_code grant is enabledop.WithCookieKeysAES-256-GCM sealing of the sessionand CSRF cookiesop.WithLoginFlowhow a user signs in: a Primary stepplus rules that add factors
The last two are conditional rather than optional. Leave either out with the code grant enabled and op.New refuses at start-up, instead of issuing unsealed cookies or answering every sign-in with a server error in production.
OptionWhy it's required
op.WithIssuerDefines the JWT iss claim, the discovery URL and the endpoint URLs, and the origin that login and logout pages accept requests from. Get this wrong and every downstream check is wrong.
op.WithStoreHolds authorization codes, sessions, refresh chains, JTI replay sets and clients. The library is storage-agnostic, so without a store it has nowhere to put state.
op.WithKeysetThe signing keys for ID tokens, JWT access tokens and JARM. The library refuses to mint tokens without an active crypto.Signer that matches the OP's signing algorithm: ECDSA P-256 for the default ES256, RSA of 2048 bits or more for RS256.
op.WithCookieKeys32 bytes of random material that key AES-256-GCM for the cookie payloads. Required when the enabled grants include authorization_code (the default grant set is authorization_code + refresh_token). An OP that serves only client_credentials can start without it. Session and CSRF cookies are encrypted, not just signed.
op.WithLoginFlowSays how a user proves who they are. Required when the grants include authorization_code: without it no user can sign in, and op.New refuses rather than failing on the first real request.

WithIssuer ​

go
op.WithIssuer("https://op.example.com")

The issuer is the OP's identity: a URL that appears as iss in every token and as the prefix of the discovery URL. Relying parties compare it byte for byte, so the library accepts only one spelling.

Issuer validation: specification and library policy

OIDC Discovery 1.0 §3 requires an HTTPS issuer URL without a query or fragment, and requires the issuer value in the metadata to match it exactly. This library additionally rejects trailing slashes, uppercase scheme or host, default ports (:443 for HTTPS, :80 for HTTP), and non-canonical paths (.., ., or duplicate slashes). For local development it permits plain HTTP only on loopback IP literals (127.0.0.0/8, [::1]); the textual host localhost needs the explicit development opt-in op.WithAllowLocalhostIssuerForDev(). The HTTP exception is library policy, not an exception in OIDC Discovery.

The check runs inside op.New (internal/discovery.ValidateIssuer), so a typo fails startup instead of producing a discovery document that relying parties reject. The strictness gives every deployment one spelling of the issuer for RFC 9207 comparisons. See Issuer for the full canonical form.

WithStore ​

go
op.WithStore(inmem.New())
// or
op.WithStore(myCompositeStore)

The store.Store interface is the union of small substore interfaces (AuthorizationCodeStore, RefreshTokenStore, SessionStore, ClientStore, …). Most deployments start from one of the bundled adapters:

BYO storage

The store interfaces are small on purpose, so you can implement them on whatever you already run: Cassandra, Spanner, etcd, or a Redis cluster with your own conventions. The contract test suite at op/store/contract checks a store against the same expectations the bundled adapters meet.

Some behaviour is opt-in through optional interfaces and extra record fields. A store that implements store.OpaqueAccessTokenChainRevoker and store.IssuanceRevocationChecker, and persists ChainID and the per-authorization fields, gets revocation scoped to one refresh chain. A store without them keeps working and revokes at the grant level, which can also end the user's other sessions on the same client.

WithKeyset ​

go
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
op.WithKeyset(op.Keyset{
  {KeyID: "k1", Signer: priv},
})

A Keyset is a slice of {KeyID, Signer} records. Signer must implement crypto.Signer. The first entry is the active key and signs new tokens; its type must match the OP's signing algorithm. By default that is ES256, which needs an ECDSA P-256 key. A vault or KMS handle works as long as the public key is P-256 and its signatures verify as ES256. Later entries stay in the JWKS so relying parties can still verify older tokens, which is how you rotate a key. op.New rejects Ed25519 and P-384 keys, and RSA keys under 2048 bits.

To sign with RS256 instead, pass an RSA key of 2048 bits or more as the active key and set the algorithm explicitly:

go
op.WithSigningAlgorithm(op.AlgRS256)

op.LoadSigningKey and op.LoadKeyset read RSA keys in PKCS#1 or PKCS#8 PEM, and private RSA JWKs. op.New fails if the active key does not match the algorithm, and it fails if a signing key's public key also appears in WithEncryptionKeyset: signing and encryption keys must be distinct. RS256 cannot be combined with profile.FAPI2Baseline, profile.FAPI2MessageSigning or profile.FAPICIBA, because FAPI 2.0 Security Profile §5.4.1 does not allow it. To change the algorithm of a running OP, see Switching the signing algorithm.

Algorithm allow-list

The OP signs all of its JWTs with one algorithm, ES256 by default or RS256 when you select it; there is no per-client choice. When it verifies client assertions, JAR request objects and DPoP proofs, it uses a closed allow-list (RS256, PS256, ES256, EdDSA) wherever the protocol allows it. HS* and none are structurally absent: no enum value exists for them, and internal/jose.ParseAlgorithm returns ok=false for those strings. That closes the algorithm-confusion attacks of RFC 7519 §6 and RFC 8725 §2.1 in the type system instead of in runtime if statements.

WithCookieKeys ​

go
key := make([]byte, 32) // exactly 32 bytes
if _, err := rand.Read(key); err != nil { /* … */ }
op.WithCookieKeys(key)

// Key rotation:
op.WithCookieKeys(currentKey, previousKey)

The 32 bytes key an AES-256-GCM cipher that encrypts the session and CSRF cookies. The first key encrypts; the remaining keys are tried for decryption only, so a rolling key swap does not sign active users out. op.New enforces this option only when the authorization_code grant is enabled. That grant is on by default, so most configurations need cookie keys, but an OP restricted to client_credentials starts without them.

The cookie scheme is fixed

Cookies always carry the __Host- prefix (no Domain, Path=/, Secure). The session cookie is SameSite=Lax and the CSRF cookies are SameSite=Strict, with a double-submit token plus an Origin / Referer check on the consent and logout POSTs. None of this is configurable; it is the minimum the library will run at.

WithLoginFlow ​

go
op.WithLoginFlow(op.LoginFlow{
  Primary: op.PrimaryPassword{Store: users}, // your own store.UserPasswordStore over your users table
})

A LoginFlow declares how a user signs in: one Primary step that identifies the user, and optional Rules that add steps such as TOTP when a condition holds. PrimaryPassword checks a password against a store.UserPasswordStore, and a production OP passes its own: a type with FindBySubject, FindByUsername and ReadPasswordHash over the users table the application already has (Bring your own user store, Integrate with an existing database). The same store supplies the ID token and /userinfo claims. For a first run with no users table, the in-memory adapter's st.UserPasswords() is the shortest stand-in, and the bundled SQL adapter's oidc_users table is the durable one for an application that has no users yet.

op.New also refuses a flow that cannot work:

  • A Primary that needs a user an earlier step already identified (StepTOTP, StepEmailOTP, StepRecoveryCode). Use PrimaryPassword, PrimaryPasskey or an ExternalStep as Primary, and put the others in Rules.
  • A PrimaryPasskey at Primary (or in a StepOneOf there) without RequireUserVerification. There it runs a passwordless login and is the only factor.
  • A step that counts failures (PrimaryPassword, StepTOTP, StepEmailOTP, StepRecoveryCode, or an ExternalStep with CountFailures) when no lockout store resolves. op.New returns op.ErrAuthnLockoutStoreRequired. Use a WithStore backend that provides AuthnLockouts() (the in-memory, SQL, DynamoDB and composite stores do), or pass op.WithAuthnLockoutStore(...).
  • A second WithLoginFlow. One provider has one login flow.
  • No source of end-user records while a grant can resolve an end user (any grant except client_credentials). The records come from the Store of an op.PrimaryPassword in this flow, which also supplies the ID Token and /userinfo claims, or from op.WithUserStore, which wins when both are set. A flow with no PrimaryPassword (passkey-only), or an OP that serves only device-code or CIBA, must pass op.WithUserStore; with a bundled adapter that is op.WithUserStore(st.Users()). The store.Store given to op.WithStore is never consulted for users.

A factor the library does not ship plugs in as an op.ExternalStep wrapping your op.Authenticator, as Primary or inside Rules. Multi-factor authentication builds on this option.

Running locally ​

The defaults assume production: https only, and outbound fetches to public addresses only. http://127.0.0.1 and http://[::1] are exempt from the issuer and redirect_uri checks, so a local OP on a loopback IP literal needs no option. Each case the IP literal does not cover has its own option, and none of them widens another:

NeedOptionAllowed in production
http://localhost in a redirect URIop.WithAllowLocalhostRedirectURIs()Yes, for native clients that really listen on localhost; op.New logs a warning
localhost as the issuer host, for example a local passkey setup whose WebAuthn RP ID must be a domainop.WithAllowLocalhostIssuerForDev()No; refused unless the issuer host is localhost
A plain-http backchannel_logout_uri or Shared Signals push endpoint on a loopback addressop.WithAllowInsecureOutboundForDev(op.OutboundBackchannel) (or op.OutboundSharedSignals)No; refused unless the issuer is a development host (a loopback IP literal, localhost, or a name under .localhost, .test, .invalid or .example)
A client JWKS, JAR request_uri, sector_identifier_uri, Client ID Metadata Document or back-channel receiver on a private networkop.WithAllowPrivateNetwork(...) with the matching op.Outbound* targetsYes, for internal deployments; each target is lifted separately

The redirect_uri and issuer exemptions do not reach the outbound SSRF gates: a client JWKS or a back-channel receiver on a loopback or private address stays refused until its target is listed. A local setup where the relying party runs at another container's address usually needs op.WithAllowPrivateNetwork with the targets of the features it uses, such as op.OutboundJWKS for a private_key_jwt client. Details are in Redirect URI, Issuer and the network options.

Also needed in practice ​

Starting is not the same as being usable. These options are not required to boot, but nearly every real OP sets them:

  • op.WithStaticClients(...) or op.WithDynamicRegistration(...): without one of them no client can authenticate. A statically seeded confidential client using client_secret_basic or client_secret_post must carry a plaintext Secret of at least 32 bytes or a SecretHash, or op.New refuses it; op.NewClientSecret returns a matching pair.
  • op.WithScope(...): registers a custom scope such as api:read. The standard OIDC scopes need no registration.
  • op.WithAuditLogger(...) and op.WithLogger(...): send audit events and logs to your own pipeline. Without op.WithLogger, the start-up diagnostics op.New writes about your configuration (for example the claim_source line naming which user store the claims are read from) go to slog.Default(), and request-time records such as the WARN for a claim dropped for having the wrong type are discarded, so set op.WithLogger from the first run.

See the Use cases for production-shaped setups.