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.

The seam runs between the OAuth records and the end-user records. The library keeps the first half; your application keeps the second, and hands the OP three read methods across the boundary.

Protocol records here, people over there
The library keeps the OAuth and OIDC records in its own store; your application keeps the end-user rows where they already are. The provider reaches across the boundary through three read calls and nothing else.op.WithUserStoreThe library ownsclients · codes · refresh tokens · grantssessions · PAR · IATs · RATs · access tokenseverything that only exists because the protocol says soYou ownmembers: member_id · email_addresspassword_phc · full_name · locale_prefthe rows your product already had before any of thisthe three calls that crossFindBySubjectreads the member row behind a subject — the source of the ID token and /userinfo claimsFindByUsernameturns a login identifier into a subject — matched against email_address hereReadPasswordHashreturns password_phc for op.PrimaryPassword to verify — the hash never leaves your side
All three are reads. The library never writes a member row, never invents one, and never needs your schema to look a particular way — only for those three questions to have answers.

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

op.WithUserStore directs /userinfo and ID Token claim reads to the application-owned projection without wrapping the SQL store. The login flow uses the same projection for password verification:

go
members := &MemberUserStore{db: db}

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

provider, err := op.New(
  op.WithStore(durable),
  op.WithUserStore(members),
  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.

Application-owned reauthentication

Account pages that change a password or enrol a second factor should re-authenticate the user before making the change. Read the stored hash through ReadPasswordHash, then pass it to op.VerifyPassword(hash, plain); do not parse the PHC encoding in application code. The helper accepts valid PHC Argon2id hashes and returns false for a wrong password, a malformed record, or parameters outside the verifier's bounds.

go
hash, err := members.ReadPasswordHash(ctx, subject)
if err != nil || !op.VerifyPassword(hash, submittedPassword) {
    // Use the same generic response for unknown, malformed, and wrong passwords.
    return ErrReauthenticationFailed
}

op.VerifyPassword only performs the comparison. The OP's brute-force gate covers its authentication flow, not an application-owned password-change or factor-enrolment endpoint, so the caller must add its own rate limiting or lockout policy.

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 source of end-user claims. It does not require storeadapter/composite because the transactional OAuth cluster stays on one SQL adapter. WithUserStore avoids a wrapper that might hide optional capabilities of that 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 && 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.