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.The library owns the protocol recordsclients · codes · grants · sessionsrefresh/access tokens · PAR · IAT · RATYou own the end-user rowsmembers: member_id · email_addresspassword_phc · full_name · locale_prefthe three reads that crossPrimaryPassword.StoreFindBySubjectreads a subject's member row — the source of ID token and /userinfo claimsFindByUsernameturns a login identifier into a subject (matched on email_address here)ReadPasswordHashreturns password_phc for op.PrimaryPassword to verify
All three are reads. The library never creates a member row, never invents one, and never needs your schema to look a particular way — only for those three questions to have answers. The single write is the optional rehash of a legacy password hash, described under Legacy password hashes.

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

Apply Store.Migrations() with your migration tool, or call Store.Migrate(ctx) once from a deploy job. oidc_users is created along with the other tables, because the release migrations alter it and Store.ValidateSchema checks it. It stays empty with this pattern. op.New checks it only when the adapter's own Users() (or UserPasswords()) is passed to PrimaryPassword.Store, StepEmailOTP.Users or op.WithUserStore; when each of those carries your own user store, it does not. Your members table gains no column.

The OP reads /userinfo and ID Token claims from the first source in this list that is set. The store.Store given to op.WithStore is never one of them; there is no third fallback.

OrderClaim sourceclaim_source in the log
1op.WithUserStoreWithUserStore
2Store of LoginFlow.Primary when it is an op.PrimaryPassword, or of the first op.PrimaryPassword with a non-nil Store inside a primary op.StepOneOfLoginFlow.Primary PrimaryPassword.Store

op.New logs the result as one INFO line with claim_source on the logger set with op.WithLogger (slog.Default() when none is set). When neither source is set and an enabled grant can resolve an end user (any grant except client_credentials), op.New refuses to start. One wiring therefore suffices: the projection that verifies the password also supplies the claims, without wrapping the SQL store.

go
members := &MemberUserStore{db: db}

provider, err := op.New(
  op.WithStore(durable),
  op.WithLoginFlow(op.LoginFlow{
    Primary: op.PrimaryPassword{Store: members},
  }),
  // required options...
)

Add op.WithUserStore only when the claims live somewhere other than the records the login authenticates against, for example a passkey-only flow, a device-code or CIBA deployment that has no PrimaryPassword, or a profile service separate from the credential table. It takes precedence over the inferred store. To read claims from a bundled adapter's own user table, pass it explicitly: op.WithUserStore(st.Users()).

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.

Only FindBySubject belongs to store.UserStore; the other two come from store.UserPasswordStore, which the built-in password step needs. A passkey-only deployment implements store.UserStore alone.

PostgreSQL spells query placeholders $1, $2; the ? used with MySQL and SQLite is not valid there.

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.

Choosing the subject ​

store.User.Subject becomes the sub claim, and every RP keys its local account on sub (with iss). OpenID Connect Core §2 requires that it is never reassigned to another user, and in practice it can never change: a new sub is a new account at every RP. Pick the column with that in mind.

CandidateUse it?
An opaque value assigned once per user and never reused (random UUID, random hex)Yes.
Email address or usernameNo. Users change them, and the RP then sees a different person.
Auto-increment row idNo. RPs can read the user count and the sign-up order from it, and some engines reissue the highest id after the row is deleted.

If the table has no such column, add one and backfill it once; that column is the only change the OP asks of your users table. Add it as nullable, fill it, then make it unique, because most engines refuse to add a unique column to a table that already has rows:

sql
-- PostgreSQL 14+
ALTER TABLE members ADD COLUMN subject text;
UPDATE members SET subject = gen_random_uuid()::text WHERE subject IS NULL;
ALTER TABLE members ALTER COLUMN subject SET NOT NULL;
CREATE UNIQUE INDEX members_subject ON members (subject);

-- MySQL 8.0
ALTER TABLE members ADD COLUMN subject VARCHAR(64) COLLATE utf8mb4_bin NULL;
UPDATE members SET subject = LOWER(HEX(RANDOM_BYTES(16))) WHERE subject IS NULL;
ALTER TABLE members MODIFY subject VARCHAR(64) COLLATE utf8mb4_bin NOT NULL,
  ADD UNIQUE KEY members_subject (subject);

-- SQLite (cannot add a UNIQUE or NOT NULL column in place)
ALTER TABLE members ADD COLUMN subject TEXT;
UPDATE members SET subject = lower(hex(randomblob(16))) WHERE subject IS NULL;
CREATE UNIQUE INDEX members_subject ON members (subject);

Your sign-up path must now set the column on every new row: generate 16 bytes with crypto/rand and hex-encode them. MySQL's UUID() is not a substitute, since it is built from the time and the host's MAC address.

Once the column exists, FindBySubject looks users up by it, and FindByUsername keeps matching on the login name. When RPs must not be able to correlate one user across clients, keep the column and enable pairwise subjects on top of it.

When you do set op.WithUserStore, pass the same value you gave op.PrimaryPassword unless the split is deliberate. When they differ by accident nothing fails: the login succeeds, resolves a subject from one set of records, and the ID token and /userinfo are then built from another. op.New logs a warning when the two values differ.

Checking your table at startup ​

op.New checks the SQL adapter's tables when it starts, and it does the same for your user store when that implements store.SchemaValidator:

go
type SchemaValidator interface {
  ValidateSchema(ctx context.Context) error
}

ValidateSchema must only read the catalog or issue a query that returns no rows, and return nil when the table matches. A failure is joined into the construction error, so a missing members table or column stops the start-up instead of the first login. A user store that does not implement it is not checked. examples/53-production-sql does this with SELECT ... FROM app_users WHERE 1 = 0.

Legacy password hashes ​

If the existing table holds bcrypt hashes, keep them: set PrimaryPassword.LegacySchemes to []op.PasswordHashScheme{op.BcryptHashScheme()} and the OP verifies a bcrypt hash at login and, when the store implements store.UserPasswordHashUpdater, replaces it with argon2id through UpdatePasswordHash. That write-back is the only write the OP makes to your user records. See Migrating legacy password hashes.

Source: examples/53-production-sql wraps an existing users table with bcrypt hashes behind op.PrimaryPassword{LegacySchemes: ...} and does not call op.WithUserStore.

Deleted and disabled users ​

When your application deletes or disables a member, make the lookups say so: return store.ErrNotFound from FindBySubject and FindByUsername.

  • Login stops for that user.
  • Refresh fails with invalid_grant and the OP tears down the grant, so the refresh-token chain cannot be used again.
  • Tokens already issued keep working until they expire, unless you revoke them. Call Provider.RevokeSubject(ctx, subject) when you disable the account; it revokes every grant of that subject, access and refresh tokens included. Three caveats on "immediately":
    • Tokens issued without a grant record (a custom grant handler's, and jwt-bearer access tokens) are not reached and run until they expire.
    • Under op.RevocationStrategyNone, JWT access tokens are not checked for revocation and live until exp; refresh tokens are still revoked. The default strategy and RevocationStrategyJTIRegistry do revoke them.
    • A resource server that verifies JWT access tokens locally, without introspection, never learns of the revocation and accepts the token until exp. Use introspection, or keep the access-token lifetime short.

Erasing a user ​

RevokeSubject retires grants and tokens only. To erase everything the OP holds about a subject, call Provider.ForgetSubject(ctx, subject).

TopicWhat ForgetSubject does
OrderDelete the user first, so that every user store the OP reads (op.WithUserStore, PrimaryPassword.Store, StepEmailOTP.Users) answers store.ErrNotFound for the subject, then call ForgetSubject. While any of them still resolves the subject it returns a configuration_error and changes nothing, because a login running concurrently would otherwise re-create a session after it was deleted.
What it erasesEvery grant with its consent record, access tokens and refresh tokens (it calls RevokeSubject first). Every browser session of the subject; sessions of other subjects stay, even when they share a browser's account chooser. The lockout record. The TOTP enrolment, recovery codes, pending email OTP challenge and registered passkeys held in the stores your login flow references. Your user stores are never written.
What it does not reachTokens issued by a custom grant handler or the jwt-bearer grant, and device-code or CIBA requests approved but not yet redeemed; they run until they expire. Interactions, pushed authorization requests and consumed JTIs, which are short-lived and not keyed by subject. Audit sinks, the Shared Signals queue, the data behind WithPreferredLocaleStore and the state of an ExternalStep, which are yours to remove. On Redis and DynamoDB, a session written before the subject index existed and not touched since stays until it expires; it cannot mint tokens once the grants are gone.
NotificationsNo Back-Channel Logout reaches relying parties and no audit event is emitted, as with RevokeSubject. Under op.WithSharedSignals, the RevokeSubject step queues a CAEP session-revoked event for each receiver the subject holds a grant with.
FailureA session store or lockout store that lacks store.SessionSubjectDeleter or store.AuthnLockoutDeleter is a configuration_error that names every missing interface and changes nothing; see the extensions a Provider method requires. Once the preconditions hold, a failing step does not stop the others, and the failures come back as one server_error. A record that is already gone counts as deleted, so repeating the call completes what is left and returns nil after success.

ForgetSubject cannot serve an application that keeps the user row, for example under a legal hold or with a soft-delete flag that FindBySubject ignores, because it refuses while the subject resolves. Call RevokeSubject and the factor stores' own Delete methods instead: Delete(ctx, subject) on store.TOTPStore, store.RecoveryStore and store.EmailOTPStore, and ListBySubject then Delete(ctx, credentialID) per credential on store.PasskeyStore. The lockout record is removed with Delete(ctx, subject) on the lockout store (store.AuthnLockoutDeleter).

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 || !checkPassword(hash, submittedPassword) {
    // Use the same generic response for unknown, malformed, and wrong passwords.
    return ErrReauthenticationFailed
}

op.VerifyPassword accepts argon2id only. When the table still holds bcrypt hashes that the login has not yet rewritten (see Legacy password hashes), checkPassword must pick the verifier from the stored prefix, as shown in Migrating bcrypt passwords. With argon2id hashes only, call op.VerifyPassword(hash, submittedPassword) directly.

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.

Standard claims must have the JSON type OpenID Connect Core §5.1 gives them: strings, except email_verified and phone_number_verified (booleans) and address (an object). Convert in FindBySubject; a database boolean that the driver scans as an integer is the usual case. Building the map with store.StandardClaims gets the types right by construction: each field has the matching Go type, unset fields are left out, and email_verified is released only alongside email. Custom claims are added to the map it returns.

sub and updated_at are not part of the map. sub is store.User.Subject. updated_at comes from store.User.UpdatedAt: set it to the time your row last changed, and the OP releases it as Unix seconds when the profile scope is granted. A zero UpdatedAt leaves the claim out, and a number you put in Claims["updated_at"] yourself takes precedence.

go
claims := store.StandardClaims{
  Name:          fullName.String,
  Email:         email.String,
  EmailVerified: emailVerified == 1, // a TINYINT column
}.Map()
claims["tenant"] = tenantID

A standard claim of another type is left out of the ID Token and /userinfo, and the logger set with op.WithLogger gets one WARN per claim name, naming the claim and its Go type. Custom claims are released as they are.

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 sessions 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.