Use case — Choosing a storage layout
The library never owns your users table, your database, or your migration tooling. It reaches storage through small store.* substore interfaces, and you decide how to back them. This page is the map: pick your entry point, and it routes you to the concrete guide.
Mental model
Two questions settle almost everything: where do the OIDC tables live (one backend, or split across two), and do you already have a schema you must fit into. The substore interfaces are identical in every case — only the backing changes.
Building your first production setup
Starting with one SQL adapter is the simplest path. Redis and composite can wait until in-flight login state or the JTI replay set becomes a measurable SQL load. Using your existing users table does not mean implementing the OIDC protocol tables yourself.
Pick your entry point
| Your situation | Layout | Guide |
|---|---|---|
| Greenfield — no schema yet | One SQL backend, adapter-owned tables | Persistent storage (SQL) |
| AWS / DynamoDB — you want an AWS-native durable backend | One DynamoDB table per substore | DynamoDB storage |
Existing database — you already run a users table and migrations | Adapter-owned oidc_* tables (renamed to fit), your identity data projected in | Renaming tables · BYO user store · BYO store backend (custom columns) |
| Scale / high churn — you want volatile state off the durable tier | composite split: durable SQL + volatile Redis | Hot/cold split |
The third branch has two traps. Authorization codes and the PAR request_uri are short-lived, so they look like volatile state, but they belong on the durable side: every member of composite.TxClusterKinds must resolve to a single backend. The consumed-jti replay set is short-lived too, but evicting a live marker reopens the replay window, so it stays durable unless the Redis instance never evicts.
Greenfield
The sql adapter ships the entire schema, so you do not design the OIDC tables. New(db, dialect) builds the store against SQLite, MySQL 8.0+, or PostgreSQL 14+ via the matching SQLite() / MySQL() / Postgres() dialect, and the adapter owns its own oidc_* tables.
- Without a migration tool: call the store's
Migrate(ctx)once from a deploy job. It applies the files fromMigrations()in order and records progress in theoidc_schema_migrationstable, so the same call creates a fresh database and brings an older one forward. - With a migration tool: apply
Migrations()with it. golang-migrate can share the version table throughx-migrations-table=oidc_schema_migrations; goose, Flyway and Atlas keep their own history table, andop-schema exportwrites the files in goose or Flyway form. - For review: the store's
Schema()returns the complete current DDL for the dialect, with anyWithNamingoverrides applied, so a review can diff the adapter's expectations against your production schema.
For a database created by an earlier release, apply the migration files from Store.Migrations() for every later release, in order, before the first request (schema/MIGRATIONS.md explains them). The schema check names the file to apply when a column is missing. The SQL guide covers the MySQL indexes and collations those files add, and Storage maintenance gives the rollout and GC order.
Existing database
You keep your database and your migrations. Two facts shape how the adapter fits alongside them.
The adapter owns fixed-shape tables.
WithNamingrenames the physical tables (oidc_clients→ whatever your convention is), but the columns are fixed — the adapter builds its queries against a known column set. An unknown logical key makesNewfail, so a typo surfaces at construction time rather than at first query.Your real
userstable stays yours. The OP needs only a subject from it: an opaque value assigned once per user and never reused. A table that already has one gains no column; one keyed only by an auto-increment id or an email address adds that one column (see Choosing the subject). Implement astore.UserStore— andstore.UserPasswordStorewhen you use the built-in password login — that reads your columns and returns astore.User, and pass it withop.WithUserStoreor as theStoreofop.PrimaryPassword. The adapter's ownoidc_userstable is checked only when you pass the adapter'sUsers()orUserPasswords(); here it is not, and it stays empty: you never migrate your user data into it.Unused tables need not exist.
op.Newchecks only the tables your configuration uses (13 for the default configuration), and run against an empty database its error names all of them at once. A few of those are checked whatever you configure and may stay empty, because revocation and token checks reach them regardless; the reference says why for each.schema/TABLES.mdlists each table'sWithNamingkey, what it holds, when the OP uses it, and its columns.Your data-access layer does not have to be
database/sql. An application built on pgx or sqlc still opens a*sql.DBfor the adapter:github.com/jackc/pgx/v5/stdlibgives one, from the same DSN or from an existingpgxpool.Poolthroughstdlib.OpenDBFromPool. Writing the protocol substores yourself is the long way round; see below.
So "reuse my existing tables" splits in two: the OIDC protocol tables (auth codes, refresh chains, grants, …) are the adapter's — rename them with WithNaming and let your migrations create them — while your identity data stays behind a bring-your-own UserStore.
Which substores are worth bringing your own
Any substore can be BYO, but the one embedders almost always own is the user projection (UserStore / UserPasswordStore), because the user record is your domain. The protocol substores (codes, tokens, grants) rarely benefit from a hand-written backend — use the sql adapter for those. See BYO store backend for a from-scratch implementation and BYO user store for the identity-only case.
Splitting for scale (hot/cold)
When one backend is no longer the right shape — durable rows don't need the QPS the volatile state generates, and volatile rows don't need durable guarantees — the composite adapter routes each substore to a durable or a volatile backend. This is the standard production shape: SQL durable, Redis volatile. Full walkthrough on the Hot/cold split page.
What may go to Redis — the rule
The Redis-or-SQL choice is not "is this data short-lived." It follows one invariant: substores that must commit atomically together (composite.TxClusterKinds) have to share a single backend, so they stay on the durable tier. Data lifetime only decides the remainder.
| Bucket | Substores | Why |
|---|---|---|
| Must be durable (SQL) | AuthorizationCodeStore, RefreshTokenStore, GrantStore, PushedAuthRequestStore, AccessTokenRegistry, OpaqueAccessTokenStore, GrantRevocationStore | Members of the atomic-routing cluster — they commit or CAS in one consistency domain. The Redis adapter returns nil for these, so composite cannot route them off the durable tier. |
| Good fit for Redis (volatile) | InteractionStore | Short-lived, high-churn, losable in isolation (the user restarts the login), and outside the cluster. |
Durable, or Redis with noeviction | ConsumedJTIStore | Outside the cluster, but an evicted replay marker lets a captured DPoP or private_key_jwt proof replay. Redis can host it only on an instance that never evicts, which then cannot use a volatile-* or allkeys-* policy for the other substores. |
| Durable | AuthnLockouts | Outside the cluster, but the brute-force counters are keyed by subject and must survive a restart and be shared by every replica. The SQL and DynamoDB adapters implement it and the Redis adapter does not; left unrouted it follows Clients. A login flow with a password or second-factor step fails op.New when no lockout store resolves (op.WithAuthnLockoutStore). |
| Your call | SessionStore | Route to either tier; declare logout-trigger/session-snapshot durability with WithSessionDurabilityPosture. BCL targets remain grant-derived, and bcl.no_sessions_for_subject records a session-bearing logout with zero eligible RP targets. |
The counter-intuitive member is PAR: its request_uri is short-lived and looks volatile, but the OP consumes it inside the authorization-code path, so it belongs to the cluster and stays durable. The authoritative per-substore table, the Redis eviction policy per substore, and the exact op.New guardrails for a nil required backend live on the Hot/cold split page.
Redis safety floor
oidcredis.New refuses to start without TLS (rediss://) and AUTH. The dev-only escape hatch is oidcredis.WithDevModeAllowPlaintext; shipping it in production is a security regression you have to type out by hand.
Keep the layout healthy
Before launch, apply the schema and verify the writer compatibility boundary. During operation, schedule SQL retention GC, monitor its GCStats, and treat DynamoDB TTL as reclamation rather than expiry enforcement. See storage maintenance, backup & disaster recovery, and the backend-specific SQL, DynamoDB, and BYO store guides.