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.
op.New refuses at start-up, instead of issuing unsealed cookies or answering every sign-in with a server error in production.| Option | Why it's required |
|---|---|
op.WithIssuer | Defines 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.WithStore | Holds 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.WithKeyset | The 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.WithCookieKeys | 32 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.WithLoginFlow | Says 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
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
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:
op/storeadapter/inmem: every substore in memory. For reference, development and tests.op/storeadapter/sql: durable substores ondatabase/sql(SQLite, MySQL, Postgres).op/storeadapter/dynamodb: a full store on DynamoDB.op/storeadapter/redis: the volatile tier (SessionStore,InteractionStore,ConsumedJTIStore), used throughcomposite.op/storeadapter/composite: a hot/cold splitter that routes durable substores to one backend and volatile substores to another.
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
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:
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
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
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
Primarythat needs a user an earlier step already identified (StepTOTP,StepEmailOTP,StepRecoveryCode). UsePrimaryPassword,PrimaryPasskeyor anExternalStepasPrimary, and put the others inRules. - A
PrimaryPasskeyatPrimary(or in aStepOneOfthere) withoutRequireUserVerification. There it runs a passwordless login and is the only factor. - A step that counts failures (
PrimaryPassword,StepTOTP,StepEmailOTP,StepRecoveryCode, or anExternalStepwithCountFailures) when no lockout store resolves.op.Newreturnsop.ErrAuthnLockoutStoreRequired. Use aWithStorebackend that providesAuthnLockouts()(the in-memory, SQL, DynamoDB and composite stores do), or passop.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 theStoreof anop.PrimaryPasswordin this flow, which also supplies the ID Token and/userinfoclaims, or fromop.WithUserStore, which wins when both are set. A flow with noPrimaryPassword(passkey-only), or an OP that serves only device-code or CIBA, must passop.WithUserStore; with a bundled adapter that isop.WithUserStore(st.Users()). Thestore.Storegiven toop.WithStoreis 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:
| Need | Option | Allowed in production |
|---|---|---|
http://localhost in a redirect URI | op.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 domain | op.WithAllowLocalhostIssuerForDev() | No; refused unless the issuer host is localhost |
A plain-http backchannel_logout_uri or Shared Signals push endpoint on a loopback address | op.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 network | op.WithAllowPrivateNetwork(...) with the matching op.Outbound* targets | Yes, 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(...)orop.WithDynamicRegistration(...): without one of them no client can authenticate. A statically seeded confidential client usingclient_secret_basicorclient_secret_postmust carry a plaintextSecretof at least 32 bytes or aSecretHash, orop.Newrefuses it;op.NewClientSecretreturns a matching pair.op.WithScope(...): registers a custom scope such asapi:read. The standard OIDC scopes need no registration.op.WithAuditLogger(...)andop.WithLogger(...): send audit events and logs to your own pipeline. Withoutop.WithLogger, the start-up diagnosticsop.Newwrites about your configuration (for example theclaim_sourceline naming which user store the claims are read from) go toslog.Default(), and request-time records such as the WARN for a claim dropped for having the wrong type are discarded, so setop.WithLoggerfrom the first run.
See the Use cases for production-shaped setups.