Skip to content

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 situationLayoutGuide
Greenfield — no schema yetOne SQL backend, adapter-owned tablesPersistent storage (SQL)
AWS / DynamoDB — you want an AWS-native durable backendOne DynamoDB table per substoreDynamoDB storage
Existing database — you already run a users table and migrationsAdapter-owned oidc_* tables (renamed to fit), your identity data projected inRenaming tables · BYO user store · BYO store backend (custom columns)
Scale / high churn — you want volatile state off the durable tiercomposite split: durable SQL + volatile RedisHot/cold split
Three entry points, by what you already have
Choosing a storage layout. A greenfield deployment takes one SQL adapter. A deployment with an existing users table keeps the OIDC tables on the SQL adapter and supplies its own user store. A deployment that wants the high-churn state off SQL routes both backends through the composite adapter.Where does the state live?the answer follows from what your deployment already runs, not from what the OP prefersGreenfieldno schema of your own yetOne SQL adapterlet the adapter own the OIDCtables, including usersthe least to buildAn existing databasea users table already runsSQL + your own UserStoreprotocol state on the adapter,identity stays where it isno migration of your usersSplitting off the churnRedis for the hot partcompositedurable substores on SQL,volatile ones on Redistwo backends to operate
These are entry points, not destinations. Starting on the left and moving right later is a configuration change: the substore split is where the routing lives, and nothing above it has to know.

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 from Migrations() in order and records progress in the oidc_schema_migrations table, 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 through x-migrations-table=oidc_schema_migrations; goose, Flyway and Atlas keep their own history table, and op-schema export writes the files in goose or Flyway form.
  • For review: the store's Schema() returns the complete current DDL for the dialect, with any WithNaming overrides 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. WithNaming renames 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 makes New fail, so a typo surfaces at construction time rather than at first query.

  • Your real users table 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 a store.UserStore — and store.UserPasswordStore when you use the built-in password login — that reads your columns and returns a store.User, and pass it with op.WithUserStore or as the Store of op.PrimaryPassword. The adapter's own oidc_users table is checked only when you pass the adapter's Users() or UserPasswords(); here it is not, and it stays empty: you never migrate your user data into it.

  • Unused tables need not exist. op.New checks 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.md lists each table's WithNaming key, 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.DB for the adapter: github.com/jackc/pgx/v5/stdlib gives one, from the same DSN or from an existing pgxpool.Pool through stdlib.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.

BucketSubstoresWhy
Must be durable (SQL)AuthorizationCodeStore, RefreshTokenStore, GrantStore, PushedAuthRequestStore, AccessTokenRegistry, OpaqueAccessTokenStore, GrantRevocationStoreMembers 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)InteractionStoreShort-lived, high-churn, losable in isolation (the user restarts the login), and outside the cluster.
Durable, or Redis with noevictionConsumedJTIStoreOutside 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.
DurableAuthnLockoutsOutside 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 callSessionStoreRoute 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.