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.
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 five methods). For an authorization-code OP you implement these non-nil:
| Substore | Interface | Methods |
|---|---|---|
| Clients | store.ClientStore | GetClient (skip ClientRegistry unless you support dynamic registration) |
| Authorization codes | store.AuthorizationCodeStore | Save / Find / Consume |
| Refresh tokens | store.RefreshTokenStore | Save / Find / Consume / RevokeChain / RevokeByGrant |
| Grants | store.GrantStore | Save / Find / FindBySubjectClient / ListBySubject / Delete / HasAny |
| Sessions | store.SessionStore | Save / Find / Touch / Delete / ListByChooserGroup |
| PAR | store.PushedAuthRequestStore | Save / Find / Consume |
| Interactions | store.InteractionStoreCAS | Save / Find / Delete / CompareAndSwap / DeleteIfUnchanged |
| Consumed JTIs | store.ConsumedJTIStore | Mark / Has |
| Users | store.UserPasswordStore | FindBySubject / FindByUsername / ReadPasswordHash |
| Access tokens | store.AccessTokenRegistry | Register / Find / RevokeByJTI / RevokeByGrant / GC |
| Metadata | store.MetadataStore | Get / Set |
The remaining substore accessors may return nil when you do not enable the matching feature — OpaqueAccessTokens, InitialAccessTokens, RegistrationAccessTokens, DeviceCodes, CIBARequests, and GrantRevocations. The library detects nil at op.New 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); the default grant-tombstone strategy requires that substore at construction time.
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 /authorize 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 /authorize |
store.InteractionStoreCAS | Store.Interactions() | a grant mounts /authorize |
store.GrantClientLister | Store.Grants() | a grant mounts /authorize |
store.RefreshRetryResponseStore | Store.RefreshTokens() | refresh_token is enabled and cookie keys are configured |
store.ClientRegistry | the aggregate Store | op.WithDynamicRegistration |
Only grant.AuthorizationCode currently mounts /authorize, so a store backing only client_credentials, device_code, or CIBA needs none of the first three.
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 the RFC 9700 delivery grace window can re-emit the exact same successor instead of branching the chain.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.StaticClientReconciler | the aggregate Store | WithStaticClients records are not reconciled against the backend |
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 |
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.
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 / scope metadata |
| User | vault_principals | principal (the subject), login_name, secret_phc |
| Authorization code | vault_grant_codes | code_digest, principal, relying_party, requested_scope, issued_epoch, expires_epoch, consumed_epoch |
| Refresh token | vault_renewal_slips | token_secret_digest, ledger_id, is_void |
| Grant | vault_consent_ledger | ledger_id, granted_scope |
| PAR | vault_pushed_handles | handle_digest |
| Session | vault_browser_seats | seat_id, chooser_band |
| Access token | vault_wire_tokens | jti, ledger_id, is_revoked |
principal is the subject, relying_party is the client id, ledger_id is the grant id — the substore implementations are the sole 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. 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). The example implements it 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. - PAR expiry belongs to
Find, notConsume.PushedAuthRequestStore.Findis the presentation-time expiry gate when the browser bringsrequest_urito/authorize.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 && go run -tags example .)The example starts the OP on :8080 and a paired RP on :9090. Sign in as demo@example.test / 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 — replace only the
Users()substore while the bundled adapter keeps the OIDC records. - Hot/cold split (Redis volatile) — route substores to different backends with the composite adapter.