Use case — Bring your own store backend
The bundled SQL adapter lets you rename tables with WithNaming, but it owns the column layout. When you need custom column names too — an existing schema you cannot reshape, encrypted columns, a table shared with other systems, or a non-SQL backend entirely — you implement the store substore interfaces yourself and pass your aggregate to op.WithStore. The library observes none of the physical names; it only sees the store.* Go structs your code maps rows onto.
Choose this path only when the SQL adapter cannot represent your persistence boundary. It gives maximum control, but it also makes you responsible for bearer-secret hashing, sentinel errors, concurrency, and transactions. If default columns are acceptable, use the SQL adapter; if only user lookup is custom, replace only the user store.
The split is not negotiable in one direction and fully open in the other. The library fixes what each operation must mean; your backend decides how the bytes are stored.
| What the library fixes | What you choose |
|---|---|
| Which substores must be non-nil | Every table and column name |
Which extensions op.New verifies | SQL, key-value, or no database at all |
| The sentinel error for each failure mode | Encryption, sharding, tenancy |
| Single-winner transitions and atomicity | How a transaction is implemented |
| Hash-on-store for bearer secrets | How a digest is computed and peppered |
op.WithStore is the seam between the two columns:
Only the store.* Go structs and the sentinel errors cross that seam. Everything on your side of it — names, engine, encoding — is invisible to the OP.
Source:
examples/26-byo-store-from-scratch— a completestore.Storeover SQLite with a hand-rolledvault_*schema, driven through a real browser login round-trip in CI.
What you implement
store.Store is an aggregate of small substore interfaces (each owns one record kind and exposes one to six methods). Which ones must be non-nil depends on the grants and features you enable; op.New checks the core substores and those your enabled grants require, and reports every missing one together in a single error. A full authorization-code OP needs these:
| Substore | Interface | Methods | Must be non-nil when |
|---|---|---|---|
| Clients | store.ClientStore | GetClient (skip ClientRegistry unless you support dynamic registration) | always |
| Users | store.UserStore, or store.UserPasswordStore for op.PrimaryPassword | FindBySubject / FindByUsername / ReadPasswordHash | any grant other than client_credentials. Not part of store.Store: pass it with op.WithUserStore, or as the Store of op.PrimaryPassword; see Bring your own user store |
| Consumed JTIs | store.ConsumedJTIStore | Mark / Has | always |
| Authorization codes | store.AuthorizationCodeStore | Save / Find / Consume | grant.AuthorizationCode |
| Refresh tokens | store.RefreshTokenStore | Save / Find / Consume / RevokeChain / RevokeByGrant | grant.RefreshToken |
| Grants | store.GrantStore | Save / Find / FindBySubjectClient / ListBySubject / Delete / HasAny | any grant other than client_credentials |
| Sessions | store.SessionStore | Save / Find / Touch / Delete / ListByChooserGroup | a grant mounts /auth |
| Interactions | store.InteractionStoreCAS | Save / Find / Delete / CompareAndSwap / DeleteIfUnchanged | a grant mounts /auth |
| PAR | store.PushedAuthRequestStore | Save / Find / Consume | feature.PAR |
| Access tokens | store.AccessTokenRegistry | Register / Find / RevokeByJTI / RevokeByGrant / GC | revocation strategy RevocationStrategyJTIRegistry (op.New refuses nil then); see the note below |
| Grant revocations | store.GrantRevocationStore | RevokeGrant / RevokeJTI / IsRevoked / GC | default strategy RevocationStrategyGrantTombstone |
| Authn lockouts | store.AuthnLockoutStore, resolved through store.AuthnLockoutProvider | see store.AuthnLockoutStore | the login flow has a password or second-factor step |
| Metadata | store.MetadataStore | Get / Set | recommended (see below) |
The lockout store holds the failure counters of the brute-force gate. When the login flow has a step that counts failures (password, second factor, or an ExternalStep with CountFailures) and none resolves, op.New fails with op.ErrAuthnLockoutStoreRequired. Either have your aggregate implement store.AuthnLockoutProvider (store.Store plus AuthnLockouts() store.AuthnLockoutStore), or pass a store with op.WithAuthnLockoutStore. The bundled adapters (inmem, SQL, DynamoDB, composite) provide one.
The access-token and opaque-access-token substores are consulted by revocation and introspection whichever strategy or format is configured, which is why the SQL adapter checks oidc_access_tokens and oidc_opaque_access_tokens in every configuration even though rows appear only under the JTI registry or an opaque format. op.New itself refuses nil only in the configurations the table names, but implement both so that tokens issued before a strategy or format change stay revocable.
The other accessors may return nil when you do not enable the matching feature: OpaqueAccessTokens (only with an opaque access-token format), InitialAccessTokens and RegistrationAccessTokens (dynamic registration), DeviceCodes, and CIBARequests. op.New detects the nil and rejects the option that would have needed it, rather than panicking later. To skip GrantRevocations you must also pin op.WithAccessTokenRevocationStrategy(op.RevocationStrategyNone) (non-FAPI deployments only).
Metadata holds the subject-issuance mode (public or pairwise) that op.New checks on every start. A nil accessor only logs a startup warning, but then nothing detects a switch between public and pairwise subjects against an existing grant store, and every affected user's sub changes silently. Implement it.
Capabilities the constructor requires
Beyond the substores themselves, the OP detects a handful of extension interfaces by runtime type assertion. A capability lives on an extension rather than on a core substore whenever some constructible OP does not need it — a machine-to-machine backend that never mounts /auth should not have to implement browser-flow machinery. What matters for a backend author is that an extension whose absence would break a configured flow is verified at op.New, not discovered on a live request. The error names both the interface and the condition that made it mandatory.
| Extension | Asserted on | Required when |
|---|---|---|
store.Transactional | the aggregate Store | a grant mounts /auth |
store.InteractionStoreCAS | Store.Interactions() | a grant mounts /auth |
store.GrantClientLister | Store.Grants() | a grant mounts /auth |
store.RefreshRetryResponseStore | Store.RefreshTokens() | refresh_token is enabled and cookie keys are configured |
store.ClientRegistry | the aggregate Store | op.WithDynamicRegistration |
store.StaticClientReconciler | the aggregate Store | op.WithStaticClients |
Only grant.AuthorizationCode currently mounts /auth, so a store backing only client_credentials, device_code, or CIBA needs none of the first three.
store.StaticClientReconciler seeds op.WithStaticClients in one atomic call at the end of op.New; without it, op.New refuses the option.
Transactionalhands out astore.TxwhoseAuthorizationCodes(),Grants(),RefreshTokens(),PushedAuthRequests(),AccessTokens(),OpaqueAccessTokens(), andGrantRevocations()are bound to one underlying transaction. Authorization completion commits the grant, PAR consumption, and code persistence together, so a signing or persistence fault cannot consume arequest_uriwithout emitting a code. Grant reads inside a transaction must lock rows, run serializable, or surface an equivalent conflict beforeSave— grouping an unlockedSELECTwith an unconditionalSavestill loses concurrent consent updates.Sessions,Interactions, andConsumedJTIsare deliberately absent fromTx.InteractionStoreCASmakes a terminal interaction immutable before those durable writes start.CompareAndSwapreplaces a record only while itsRawStateis unchanged (ErrConflictotherwise,ErrNotFoundwhen absent or expired), andDeleteIfUnchangedremoves it only if nothing raced.GrantClientListeris the bounded audience view Back-Channel Logout fans out from:ListClientIDsBySubject(ctx, subject, cursor, limit)returns at mostlimitdistinct client IDs in a stable ascending order, plus aNextCursorwhen another page exists. Bound the query itself tolimit+1rows — implementing it by callingListBySubjectand slicing the result defeats the point, which is capping database and client-registry work per logout notice.RefreshRetryResponseStorepersists an already-sealed token response against its consumed predecessor so this library's refresh retry grace window can re-emit the same successor instead of branching the chain. RFC 9700 §4.14.2 describes rotation and replay detection, not this grace window.SaveRotationWithRetrymust write the successor and the sealed blob in one operation; a backend that cannot make that atomic must not expose the interface at all. Treat the blob as opaque, key it by a one-way digest of the predecessor, and retain it no longer than the predecessor's own lifetime.
These stay optional, and the OP degrades rather than refusing to start:
| Extension | Asserted on | What you lose |
|---|---|---|
store.RevokeByClient | Store.RefreshTokens() and the access-token substores | deleting a dynamically registered client skips that substore's bulk credential cascade |
store.RefreshChainResolver | Store.RefreshTokens() | chain walks resolve through Find instead of the stored-handle lookup |
store.OpaqueAccessTokenChainRevoker | Store.OpaqueAccessTokens() | a refresh rotation retires the opaque access tokens of the whole grant, not only its own chain |
store.IssuanceRevocationChecker | Store.GrantRevocations() | revoking one refresh token refuses the grant's other refresh chains and unredeemed codes too |
store.SchemaValidator | every store the configuration uses | op.New skips the construction-time schema check (ValidateSchema only reads the catalog), so a missing table or column shows up on the first request that touches it |
Extensions a Provider method requires
A method on the provider is a call, not a configuration, so op.New does not check what it needs. The method verifies it before its first write instead:
| Extension | Asserted on | Required by | Result when absent |
|---|---|---|---|
store.SessionSubjectDeleter | Store.Sessions() | Provider.ForgetSubject | configuration_error, nothing is changed |
store.AuthnLockoutDeleter | the lockout store | Provider.ForgetSubject | configuration_error, nothing is changed |
SessionSubjectDeleter adds DeleteBySubject(ctx, subject), which removes every session of the subject, expired or not, and keeps the sessions of other subjects, including those sharing a chooser group. It returns nil when the subject holds none, and an empty subject is an error. AuthnLockoutDeleter adds Delete(ctx, subject), which returns store.ErrNotFound when no record exists. The bundled in-memory, SQL and DynamoDB adapters implement both, Redis implements the session one because it has no lockout store, and the composite adapter returns its backends' stores unchanged. A backend that never calls ForgetSubject can skip them. See Erasing a user.
Verify the placement, don't infer it
op/store/contract runs the core contract against your backend and skips each extension you do not implement, so the suite tells you which capabilities you actually landed. The user store is not an accessor of store.Store, so the suite takes it in the Backend.Users field and skips the user cases when it is nil.
A hand-written wrapper around a bundled store hides these interfaces unless it forwards them: a type assertion on the wrapper fails even though the wrapped store implements the method, and nothing errors. op.New logs one INFO line (to the op.WithLogger logger, or to slog.Default() without one) listing each optional capability the configuration would use but the store does not implement, in the form <substore>: <interface>. The list covers store.IssuanceRevocationChecker and store.OpaqueAccessTokenChainRevoker, and with dynamic registration also store.GrantSubjectLister and store.RevokeByClient. The OP still starts and uses the fallbacks above, so read that line after introducing a wrapper.
Concurrency contracts to implement
The current store interfaces make several security-sensitive read-modify-write rules explicit. They are backend contracts, not hints to approximate:
store.TOTPRecord.Versionandstore.EmailOTPRecord.Versionare non-zero, store-issued opaque tokens after persistence. They are equality-only: do not increment them, compare their order, reuse one after delete/recreate, or expose them in the JSON document.Putassigns a fresh token without mutating the caller's record;CompareAndSwaprequiresnext.Version == previous.Version, carries the token read by the caller, compares the stored record field-for-field (includingVersion), and assigns a fresh successor without mutating either input. A stale or malformed snapshot returnsstore.ErrAlreadyConsumed.store.CIBARequestStore.Approveis an atomic Pending → Approved transition. If a deferred record already has a non-emptySubject, a different subject returnsstore.ErrConflictand leaves the record untouched; an empty subject may be populated exactly once. Approving a missing or expired record returnsstore.ErrNotFound, and approving a record that is no longer Pending returnsstore.ErrConflict.Consumeseparately performs the Approved → Consumed single-use transition, returningstore.ErrAlreadyConsumedfor a consumed record andstore.ErrConflictfor Pending or Denied.- CIBA is outside the aggregate's
store.Transactionalcluster;ApproveandConsumemust provide their own atomic transitions rather than approximating them with a read followed by a write. store.InteractionStoreCAScomparesRawStatebyte-for-byte. Preserve the bytes exactly and makeCompareAndSwap/DeleteIfUnchangedatomic; re-encoding JSON or normalizing whitespace changes the version and causes a legitimate completion to lose withstore.ErrConflict. A replacement whoseRawStateand other fields are identical is still a successful apply; do not infer a conflict from an affected-row count of zero.store.GrantRevocationStore.RevokeGrantis idempotent and only ever widens a tombstone: concurrent calls converge on the latestRevokedAtandExpiresAt. Use a row lock,GREATEST(), or a conditional write; a read followed by a write of the value the caller decided lets the cascade that read first land last and revive access tokens the other cascade just revoked.store.GrantStore.Saveamends an existing grant. Lock the row through the transaction, use serializable isolation, or reject a stale basis withstore.ErrConflict; an unlocked read followed by an unconditional save can silently drop a concurrent consent update.
The SQL adapter stores these version tokens in oidc_totp_secrets.row_version and oidc_email_otps.row_version; see the SQL migration order before starting a version-aware writer. The contract suite is the executable reference for each sentinel and transition.
The contract harness's contract.TOTPFactory returns a contract.TOTPBackend, whose Store field carries the store.TOTPStore; it does not return a bare store.TOTPStore. Its optional Diverge hook can mutate a stored record out of band while keeping Version unchanged, so the backend must enforce the full-record compare-and-swap rule.
Those SQL names are adapter-specific. A BYO backend may choose different columns or a non-SQL representation, but its registration-token store still has to persist RegistrationAccessToken.AllowedScopes, and its MFA stores still need equivalent opaque compare-and-swap state before the first request.
Per-authorization state and refresh chains
A grant is shared by every authorization of one subject on one client, and each new authorization rewrites it. To keep an earlier code, refresh chain, or access token from reporting a later authorization, the OP copies the state of the authorization onto the record it issues. Persist these fields and return them unchanged:
| Record | Fields to round-trip |
|---|---|
store.AuthorizationCode | AuthTime, ACR, AMR, AuthorizationDetails, Claims, AuthorizationRecorded |
store.RefreshToken | AuthTime, ACR, AMR, AuthorizationDetails, Claims, AuthorizationRecorded, ChainID |
store.OpaqueAccessToken | AuthTime, ACR, AMR, AuthorizationDetails, Claims, AuthorizationRecorded, ChainID |
store.GrantTombstone | AccessTokensOnly |
AuthorizationRecordedistruewhen the other fields were written at issuance. A row that reads backfalseis resolved from its grant instead. A backend that stores none of these fields therefore still works, but a later authorization by the same user and client changes what its earlier tokens report. Store the fields together or not at all.ChainIDis a random, non-secret value allocated at the chain root and copied onto every rotation. A refresh rotation retires only the opaque access tokens that carry its ownChainID, so refreshing on one device does not end the user's other sessions on the same client. Implementstore.OpaqueAccessTokenChainRevoker.RevokeByChain(ctx, grantID, chainID)to enable this; an emptychainIDmatches nothing.AccessTokensOnlymarks the tombstone that/revokewrites for a single refresh token. It covers the grant's access tokens but must not refuse the grant's other refresh chains or unredeemed codes. Keep its cutoff apart from the whole-grant one:IsRevokedhonours both, andstore.IssuanceRevocationChecker.IsIssuanceRevokedhonours only the whole-grant cutoff. A backend that drops the field over-revokes: every tombstone then ends the whole grant.
A store that implements neither optional interface keeps the grant-wide behaviour: revoking or rotating one chain affects every chain under the grant.
Client secrets: current and previous
store.Client carries two fields beside SecretHash that let a confidential client rotate its secret without downtime (see Rotating a secret without downtime). A ClientStore that persists clients must round-trip both:
| Field | Meaning | Zero value |
|---|---|---|
PreviousSecretHash | The hash of the secret in use before the last rotation, in the same encoding as SecretHash | no previous secret |
PreviousSecretExpiresAt | The instant after which PreviousSecretHash stops authenticating, compared at second precision | no previous secret |
Store the expiry as epoch seconds and read it back as time.Unix(n, 0).UTC(), with 0 standing for the zero time.Time; the bundled adapters do exactly this. A non-empty hash with a zero expiry is never accepted, so the zero value is the safe default for a record that predates the fields.
A backend that ignores them keeps working: the current secret authenticates as before. Only the overlap is lost, since a previous secret can never be read back and so is never accepted. A static client seeded with a previous secret then differs from its stored record on every comparison, so op.SyncStaticClients reports it as updated on each run. For the SQL adapter, an existing database needs the oidc_clients columns before the new binary starts, and ValidateSchema reports the missing ones; Store.Migrate applies them, and the statements for each dialect are in MIGRATIONS.md beside the schema files. The contract suite checks the round-trip.
When op.New refuses a static seed that differs from the stored client, the error names the differing fields and points at op.SyncStaticClients. A custom backend that wants the same list in its own tooling calls store.StaticClientDifferences(a, b *store.Client), which returns those field names in store.Client declaration order, or nil when the two records are equivalent or either is nil.
store.Client also carries the five RFC 8705 §2.1.2 matchers (TLSClientAuthSubjectDN, TLSClientAuthSANDNS, TLSClientAuthSANURI, TLSClientAuthSANIP, TLSClientAuthSANEmail). Persist them if the backend should serve tls_client_auth clients; a backend that drops them cannot match those clients' certificates.
Column names are yours
The example proves the point by giving every table and column a deliberately non-OIDC name. Nothing in the library cares:
| Store record | Example table | Example columns |
|---|---|---|
| Client | vault_relying_parties | relying_party, redirect_targets, permitted_scope |
| User | vault_principals | principal (the subject), login_handle, secret_phc |
| Authorization code | vault_grant_codes | token_secret_digest, principal, relying_party, ledger_id, requested_scope, expires_epoch, consumed_epoch |
| Refresh token | vault_renewal_slips | token_secret_digest, ledger_id, is_void |
| Grant | vault_consent_ledger | ledger_id, principal, relying_party, requested_scope, is_revoked |
| PAR | vault_pushed_handles | handle_digest |
| Session | vault_browser_seats | seat_id, chooser_band |
| Access token | vault_wire_tokens | ticket_id (the jti), ledger_id, is_void |
principal is the subject, relying_party is the client id, and ledger_id is the grant id. The substore implementations are the only place the physical schema is mapped onto the store.* structs.
Contracts you must honour
The substore godoc is normative. A backend that compiles but ignores these does not satisfy the interface:
- Hash-on-store.
AuthorizationCode.ID,RefreshToken.ID, andPushedAuthRequest.URIare opaque bearer secrets: possession alone redeems them. Hash the presented value (SHA-256, ideally HMAC'd with a server-side pepper) before persisting, store only the digest, and onFind/Consumehash the presented value to look the digest up, comparing in constant time. The example uses SHA-256 without a pepper to stay self-contained, matching the in-memory reference; production backends SHOULD add the pepper. - Sentinel errors. Return
store.ErrNotFound,store.ErrAlreadyExists,store.ErrAlreadyConsumed,store.ErrConflict, andstore.ErrTxRequiredexactly where the method godoc says (mapsql.ErrNoRows→ErrNotFound; a secondConsume→ErrAlreadyConsumed). Callers switch on these witherrors.Is; returning a different error for a listed failure mode breaks the contract even though it compiles. - Atomicity and single-winner transitions. Exchanging a code, rotating a refresh token, and consuming a PAR each cross several record kinds. The library relies on each substore's
Save/Consumebeing individually atomic, and a backend serving the browser authorization-code flow MUST implementstore.Transactionalso multi-substore writes share one underlying transaction (see Capabilities the constructor requires). For every valid live single-use or conditional transition, exactly one concurrent caller may succeed and losers must receive the documented sentinel; an idempotent replacement whose complete record is unchanged is still a success. A backend must not infer a conflict from an affected-row count of zero. Single-useConsume/ TOTPAcceptmust persist the consumed or progress state before returning nil, and Email OTPConsumemust persist a non-zeroConsumedAteven when its inputConsumedAtwas zero. The example implements transactions the same way the bundled adapter does: substores take a smallquerierinterface that both*sql.DBand*sql.Txsatisfy, andBeginTxhands out cluster substores bound to one*sql.Tx. - Authorization-code expiry precedes consumed-state checks.
AuthorizationCodeStore.Consumemust returnstore.ErrNotFoundwhen a code is both expired and already consumed; expiry wins. A live consumed code returnsstore.ErrAlreadyConsumed(and SHOULD return the record when available so the caller can recover itsGrantIDfor the RFC 6749 §4.1.2 replay cascade). ThatErrAlreadyConsumedresult is replay evidence and triggers the cascade; expiry alone must remain an ordinary invalid-grant failure. A successful consume returns a non-zeroConsumedAt. - Refresh rotation preserves replay evidence.
RefreshTokenStore.Consumeapplies the same expiry-first rule: an expired-and-consumed row returnsstore.ErrNotFound, while a live replay returnsstore.ErrAlreadyConsumedtogether with the record so the OP can recover the chain root. A rotation save must atomically refuse a parent markedRevokedwithstore.ErrAlreadyConsumed, without leaving a redeemable child. A parent that is absent—possibly because retention already collected it—does not prove revocation, so the child is kept.SaveRotationWithRetryfollows the same parent rule. - Session, interaction, and device-code records have narrow lifecycle contracts.
SessionStore.Touchchanges onlyExpiresAtandUpdatedAt, preserves every other field and secondary index, and treats an unchanged replacement as success. A past-datedSessionStore.SaveorInteractionStore.Savemay omit the expired record, but it must remove or supersede a live record with the same ID rather than silently leaving it active.DeviceCodeStore.FindByUserCodereturns the matching public state withIDblank so a user code cannot disclose the redeemabledevice_codecredential. - PAR expiry belongs to
Find, notConsume.PushedAuthRequestStore.Findis the presentation-time expiry gate when the browser bringsrequest_urito/auth.Consumeenforces single-use only and must not reject solely becauseExpiresAtpassed after presentation; otherwise a long login / MFA / consent interaction could fail at code emission after the OP had already accepted the request.
Which approach fits
| You want | Use |
|---|---|
| Default tables, just persistence | SQL adapter |
| Your own table names, default columns | SQL adapter + WithNaming |
| Keep your existing users table, default OIDC records | Bring your own user store |
| Your own table and column names everywhere, or a non-SQL backend | This page |
Run it
(cd examples/26-byo-store-from-scratch && GOWORK=off go run -tags example .)The example starts the OP on :8080 and a paired RP on :9090. Sign in as [email protected] / demo; the RP's /me page shows the released ID Token claims, served entirely from the vault_* schema.
Read next
- Persistent storage (SQL) — the bundled adapter and
WithNamingtable renaming. - Bring your own user store — supply only the user store while the bundled adapter keeps the OIDC records.
- Hot/cold split (Redis volatile) — route substores to different backends with the composite adapter.