Skip to content

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 fixesWhat you choose
Which substores must be non-nilEvery table and column name
Which extensions op.New verifiesSQL, key-value, or no database at all
The sentinel error for each failure modeEncryption, sharding, tenancy
Single-winner transitions and atomicityHow a transaction is implemented
Hash-on-store for bearer secretsHow a digest is computed and peppered

op.WithStore is the seam between the two columns:

What crosses the store seam
The library holds what each store operation has to guarantee; your backend holds how the data is actually kept. Only the store package's Go structs and its sentinel errors cross between the two.op.WithStoreThe librarymeaningwhat "consume this code" has to guaranteeand which error stands for which outcomeYour backendrepresentationrows, items, keys, TTLs — whatever fitsthe database you already runstore.*store.Err*
Nothing else crosses. That is what makes an adapter reviewable: the questions it can get wrong are all about representation, and every one of them has an answer in the interface contract.

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 complete store.Store over SQLite with a hand-rolled vault_* 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:

SubstoreInterfaceMethods
Clientsstore.ClientStoreGetClient (skip ClientRegistry unless you support dynamic registration)
Authorization codesstore.AuthorizationCodeStoreSave / Find / Consume
Refresh tokensstore.RefreshTokenStoreSave / Find / Consume / RevokeChain / RevokeByGrant
Grantsstore.GrantStoreSave / Find / FindBySubjectClient / ListBySubject / Delete / HasAny
Sessionsstore.SessionStoreSave / Find / Touch / Delete / ListByChooserGroup
PARstore.PushedAuthRequestStoreSave / Find / Consume
Interactionsstore.InteractionStoreCASSave / Find / Delete / CompareAndSwap / DeleteIfUnchanged
Consumed JTIsstore.ConsumedJTIStoreMark / Has
Usersstore.UserPasswordStoreFindBySubject / FindByUsername / ReadPasswordHash
Access tokensstore.AccessTokenRegistryRegister / Find / RevokeByJTI / RevokeByGrant / GC
Metadatastore.MetadataStoreGet / 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.

ExtensionAsserted onRequired when
store.Transactionalthe aggregate Storea grant mounts /authorize
store.InteractionStoreCASStore.Interactions()a grant mounts /authorize
store.GrantClientListerStore.Grants()a grant mounts /authorize
store.RefreshRetryResponseStoreStore.RefreshTokens()refresh_token is enabled and cookie keys are configured
store.ClientRegistrythe aggregate Storeop.WithDynamicRegistration

Only grant.AuthorizationCode currently mounts /authorize, so a store backing only client_credentials, device_code, or CIBA needs none of the first three.

  • Transactional hands out a store.Tx whose AuthorizationCodes(), Grants(), RefreshTokens(), PushedAuthRequests(), AccessTokens(), OpaqueAccessTokens(), and GrantRevocations() 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 a request_uri without emitting a code. Grant reads inside a transaction must lock rows, run serializable, or surface an equivalent conflict before Save — grouping an unlocked SELECT with an unconditional Save still loses concurrent consent updates. Sessions, Interactions, and ConsumedJTIs are deliberately absent from Tx.
  • InteractionStoreCAS makes a terminal interaction immutable before those durable writes start. CompareAndSwap replaces a record only while its RawState is unchanged (ErrConflict otherwise, ErrNotFound when absent or expired), and DeleteIfUnchanged removes it only if nothing raced.
  • GrantClientLister is the bounded audience view Back-Channel Logout fans out from: ListClientIDsBySubject(ctx, subject, cursor, limit) returns at most limit distinct client IDs in a stable ascending order, plus a NextCursor when another page exists. Bound the query itself to limit+1 rows — implementing it by calling ListBySubject and slicing the result defeats the point, which is capping database and client-registry work per logout notice.
  • RefreshRetryResponseStore persists 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. SaveRotationWithRetry must 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:

ExtensionAsserted onWhat you lose
store.StaticClientReconcilerthe aggregate StoreWithStaticClients records are not reconciled against the backend
store.RevokeByClientStore.RefreshTokens() and the access-token substoresdeleting a dynamically registered client skips that substore's bulk credential cascade
store.RefreshChainResolverStore.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.

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.Version and store.EmailOTPRecord.Version are 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. Put assigns a fresh token without mutating the caller's record; CompareAndSwap requires next.Version == previous.Version, carries the token read by the caller, compares the stored record field-for-field (including Version), and assigns a fresh successor without mutating either input. A stale or malformed snapshot returns store.ErrAlreadyConsumed.
  • store.CIBARequestStore.Approve is an atomic Pending → Approved transition. If a deferred record already has a non-empty Subject, a different subject returns store.ErrConflict and leaves the record untouched; an empty subject may be populated exactly once. Approving a missing or expired record returns store.ErrNotFound, and approving a record that is no longer Pending returns store.ErrConflict. Consume separately performs the Approved → Consumed single-use transition, returning store.ErrAlreadyConsumed for a consumed record and store.ErrConflict for Pending or Denied.
  • CIBA is outside the aggregate's store.Transactional cluster; Approve and Consume must provide their own atomic transitions rather than approximating them with a read followed by a write.
  • store.InteractionStoreCAS compares RawState byte-for-byte. Preserve the bytes exactly and make CompareAndSwap / DeleteIfUnchanged atomic; re-encoding JSON or normalizing whitespace changes the version and causes a legitimate completion to lose with store.ErrConflict. A replacement whose RawState and other fields are identical is still a successful apply; do not infer a conflict from an affected-row count of zero.
  • store.GrantStore.Save amends an existing grant. Lock the row through the transaction, use serializable isolation, or reject a stale basis with store.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.

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 recordExample tableExample columns
Clientvault_relying_partiesrelying_party, redirect / scope metadata
Uservault_principalsprincipal (the subject), login_name, secret_phc
Authorization codevault_grant_codescode_digest, principal, relying_party, requested_scope, issued_epoch, expires_epoch, consumed_epoch
Refresh tokenvault_renewal_slipstoken_secret_digest, ledger_id, is_void
Grantvault_consent_ledgerledger_id, granted_scope
PARvault_pushed_handleshandle_digest
Sessionvault_browser_seatsseat_id, chooser_band
Access tokenvault_wire_tokensjti, 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:

  1. Hash-on-store. AuthorizationCode.ID, RefreshToken.ID, and PushedAuthRequest.URI are 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 on Find / Consume hash 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.
  2. Sentinel errors. Return store.ErrNotFound, store.ErrAlreadyExists, store.ErrAlreadyConsumed, store.ErrConflict, and store.ErrTxRequired exactly where the method godoc says (map sql.ErrNoRowsErrNotFound; a second ConsumeErrAlreadyConsumed). Callers switch on these with errors.Is; returning a different error for a listed failure mode breaks the contract even though it compiles.
  3. 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 / Consume being individually atomic, and a backend serving the browser authorization-code flow MUST implement store.Transactional so 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-use Consume / TOTP Accept must persist the consumed or progress state before returning nil, and Email OTP Consume must persist a non-zero ConsumedAt even when its input ConsumedAt was zero. The example implements transactions the same way the bundled adapter does: substores take a small querier interface that both *sql.DB and *sql.Tx satisfy, and BeginTx hands out cluster substores bound to one *sql.Tx.
  4. Authorization-code expiry precedes consumed-state checks. AuthorizationCodeStore.Consume must return store.ErrNotFound when a code is both expired and already consumed; expiry wins. A live consumed code returns store.ErrAlreadyConsumed (and SHOULD return the record when available so the caller can recover its GrantID for the RFC 6749 §4.1.2 replay cascade). That ErrAlreadyConsumed result is replay evidence and triggers the cascade; expiry alone must remain an ordinary invalid-grant failure. A successful consume returns a non-zero ConsumedAt.
  5. Refresh rotation preserves replay evidence. RefreshTokenStore.Consume applies the same expiry-first rule: an expired-and-consumed row returns store.ErrNotFound, while a live replay returns store.ErrAlreadyConsumed together with the record so the OP can recover the chain root. A rotation save must atomically refuse a parent marked Revoked with store.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. SaveRotationWithRetry follows the same parent rule.
  6. Session, interaction, and device-code records have narrow lifecycle contracts. SessionStore.Touch changes only ExpiresAt and UpdatedAt, preserves every other field and secondary index, and treats an unchanged replacement as success. A past-dated SessionStore.Save or InteractionStore.Save may omit the expired record, but it must remove or supersede a live record with the same ID rather than silently leaving it active. DeviceCodeStore.FindByUserCode returns the matching public state with ID blank so a user code cannot disclose the redeemable device_code credential.
  7. PAR expiry belongs to Find, not Consume. PushedAuthRequestStore.Find is the presentation-time expiry gate when the browser brings request_uri to /authorize. Consume enforces single-use only and must not reject solely because ExpiresAt passed 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 wantUse
Default tables, just persistenceSQL adapter
Your own table names, default columnsSQL adapter + WithNaming
Keep your existing users table, default OIDC recordsBring your own user store
Your own table and column names everywhere, or a non-SQL backendThis page

Run it

sh
(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.