Skip to content

Use case — Bring your own user store

You already have a users, members, employees, or accounts table, and it is not shaped like the OP's bundled oidc_users table. Keep that table as the source of truth. The OP only needs a projection that can resolve a subject, release authorised claims, and, if you use password login, read the password hash through the store.UserPasswordStore contract.

Source: examples/24-byo-userstore

Shape

The example uses two storage halves:

ResponsibilityBacking store
OAuth / OIDC records: clients, authorization codes, refresh tokens, grants, sessions, PAR, IATs, RATs, access tokensbundled op/storeadapter/sql schema
End-user records: subject, email, name, locale, password hash, tenant metadataembedder-owned members table

hybridStore embeds *oidcsql.Store and overrides only Users(). Go method promotion leaves every other substore on the SQL adapter, while /userinfo, ID Token assembly, and password login read from the application-owned member projection.

go
type hybridStore struct {
  *oidcsql.Store
  users store.UserPasswordStore
}

func (h *hybridStore) Users() store.UserStore { return h.users }

The login flow then uses the same projection for password verification:

go
members := &MemberUserStore{db: db}
storage := &hybridStore{Store: durable, users: members}

flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: members},
}

provider, err := op.New(
  op.WithStore(storage),
  op.WithLoginFlow(flow),
  // required options...
)

Projection contract

Your user-store adapter normally implements:

MethodWhat it does
FindBySubject(ctx, sub)Loads the stable OIDC subject and claim map for /userinfo and token assembly.
FindByUsername(ctx, username)Resolves a login identifier such as email address to the same stable subject.
ReadPasswordHash(ctx, subject)Returns the PHC-encoded password hash for op.PrimaryPassword; return store.ErrNotFound for unknown or passwordless users.

Column names are irrelevant. In the example, member_id, email_address, password_phc, full_name, locale_pref, and tenant_id are projected onto store.User.Subject and store.User.Claims.

Claim release

Putting a value in store.User.Claims does not automatically release it to every RP. The OP still applies scope and claims-request filtering. The example deliberately loads a custom tenant claim from the member row, but the demo RP does not receive it because no granted scope authorises it.

Use Public / internal scopes when you want to release application-specific claims through a scope, or Claims request when an RP needs fine-grained claim selection.

When to use composite instead

This pattern replaces only the Users() substore. It does not require storeadapter/composite because the transactional OAuth cluster stays on one SQL adapter.

Use Hot/cold + Redis when you want to route multiple substores to different backends, for example durable grants and refresh tokens on SQL, but interactions and consumed JTIs on Redis.

Run it

sh
(cd examples/24-byo-userstore && 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.