Skip to content

Integrate with an existing database ​

You have an application with a users table and a database, and you want it to sign users in as an OpenID Provider. The OP keeps its own records (clients, codes, tokens, sessions) in tables it creates, and reads your users through three methods. Your users table is never moved or replaced.

1. Give each user a subject ​

Every user needs an opaque identifier that is assigned once and never reused. It becomes the sub claim, and every relying party keys its local account on it. An email address, a username or an auto-increment id does not qualify.

Check the subject column against this list:

  • At most 255 bytes. The OP's own subject columns are VARCHAR(255).
  • Compared byte for byte, case-sensitively. The OP's columns use utf8mb4_bin on MySQL, so Alice and alice are two subjects.
  • Unique across the table.
  • Never reused for another person and never reassigned.

If your table has no such column, add one and backfill it once. It is the only change the OP asks of your users table. Choosing the subject has the SQL for PostgreSQL, MySQL and SQLite.

Where the OP reads users from depends on what you pass:

You passThe OP readsUse it when
op.PrimaryPassword{Store: ...}login and claims, both from that storeyour users table holds the passwords (the usual case)
op.WithUserStore(...)claims from that storeclaims live elsewhere, or sign-in is not by password
the SQL adapter's own Users() (table oidc_users)login and claims from the OP's tableyou have no users table of your own

2. Write the user store ​

Write a type with three methods over your users table. Each returns store.ErrNotFound for an unknown user.

go
func (u users) FindBySubject(ctx context.Context, sub string) (*store.User, error)
func (u users) FindByUsername(ctx context.Context, login string) (*store.User, error)
func (u users) ReadPasswordHash(ctx context.Context, sub string) ([]byte, error) // PHC argon2id, or the legacy hash as stored

FindBySubject loads the user's claims, FindByUsername turns a login identifier into a subject, and ReadPasswordHash returns the stored hash. The claims a scope releases are served from /userinfo, as OpenID Connect Core §5.4 specifies when an access token is issued; the ID token carries them only for the scopes listed in op.WithIDTokenScopeClaims, such as op.WithIDTokenScopeClaims("profile", "email"). PostgreSQL spells query placeholders $1, $2, not ?. Bring your own user store covers the contract and the claim types.

bcrypt hashes. The OP verifies argon2id natively. A users table that holds bcrypt hashes needs PrimaryPassword.LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()} in step 4, and ReadPasswordHash returns each hash exactly as stored. Without the scheme the OP finds no verifier for a $2a$, $2b$ or $2y$ hash and every such user fails to sign in. The first unrecognised hash logs one WARN per process, naming neither the user nor the hash, so check the logger set with op.WithLogger. With the scheme set, each hash is rewritten as argon2id on its owner's next successful login when the store also implements store.UserPasswordHashUpdater; Migrating bcrypt passwords covers that write-back. Leave UserPasswordHashUpdater unimplemented while another application still verifies passwords against the same column, because it cannot read the argon2id hash written back.

3. Apply the OP's migrations ​

The SQL adapter owns the OP's tables. Open it on your existing connection and apply its migrations with the migration tool you already run:

go
db, err := sql.Open("mysql", dsn) // your existing database
if err != nil {
    log.Fatal(err)
}
st, err := oidcsql.New(db, oidcsql.MySQL()) // op/storeadapter/sql
if err != nil {
    log.Fatal(err)
}
// Either apply st.Migrations() with your migration tool,
// or run err = st.Migrate(ctx) once from a deploy job.

With golang-migrate, hand st.Migrations() to the iofs source and give the OP its own version table. The blank import github.com/golang-migrate/migrate/v4/database/mysql registers the mysql:// driver:

go
src, err := iofs.New(st.Migrations(), ".")
if err != nil {
    log.Fatal(err)
}
m, err := migrate.NewWithSourceInstance("iofs", src,
    "mysql://user:pass@tcp(db:3306)/app?x-migrations-table=oidc_schema_migrations")
if err != nil {
    log.Fatal(err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
    log.Fatal(err)
}

The OP's files and your application's migrations are both numbered from 1. Sharing the default schema_migrations table would make the OP's migrations look already applied, so they would be skipped. Each file holds several statements. golang-migrate turns on multi-statement execution by itself for a mysql:// URL like the one above; an existing *sql.DB passed to mysql.WithInstance needs multiStatements=true in its own DSN. Store.Migrate does not need it. For PostgreSQL, see Persistent storage (SQL).

goose and Flyway read files from disk, not from an fs.FS. op-schema writes the same files with each tool's names and headers, without a database connection:

sh
go run github.com/libraz/go-oidc-provider/op/storeadapter/sql/cmd/op-schema \
    export --dialect mysql --prefix oidc_ --format goose --out db/oidc

--dialect is mysql, postgres or sqlite. --format is golang-migrate, goose or flyway. --prefix takes the value you pass to oidcsql.WithTablePrefix (default oidc_), and --out is created when missing. --naming key=name (repeatable) applies the same table-name rewrite as oidcsql.WithNaming, for example --naming users=app_oidc_users, so the generated files match the names the store queries. Keep the OP's files in a directory and a version table of their own, next to your application's. MIGRATIONS.md shows the table setting for each tool.

Store.Migrations() returns the migration files as an fs.FS in the golang-migrate naming convention. Apply all of them, even the ones for tables your configuration leaves unused. A table whose feature you leave off stays empty, so a schema review sees more oidc_* tables than the OP writes to; which tables a configuration reads lists the ones yours uses. Without a migration tool, Store.Migrate(ctx) applies them in order and records the progress in the oidc_schema_migrations table; call it once from a deploy job rather than from every replica's startup, with a context long enough for the DDL. The oidc_users table they create stays empty while your own user store serves the users, and needs no sync. Persistent storage (SQL) describes the order, and TABLES.md lists the tables.

Expired codes, tokens and sessions are not deleted in the request path, so these tables grow until a retention sweep runs. Start one next to the provider. One replica is enough; sweeps that overlap on several replicas are safe, so no leader election is needed:

go
go func() {
    if err := st.RunGC(ctx, time.Hour, nil); err != nil { // sweeps now, then hourly
        log.Print(err)
    }
}()

Schedule retention GC covers a sweep driven by your own scheduler instead.

4. Construct the provider ​

Load the keys from files and pass them with the store and the user store to op.New:

go
keys, err := op.LoadKeyset("signing.pem") // PEM private key
if err != nil {
    log.Fatal(err)
}
cookieKeys, err := op.LoadSymmetricKeys("cookie.key") // 32 bytes, AES-256-GCM
if err != nil {
    log.Fatal(err)
}

handler, err := op.New(
    op.WithIssuer("https://idp.example.com"),
    op.WithStore(st),
    op.WithKeyset(keys),
    op.WithCookieKeys(cookieKeys...),
    op.WithLogger(slog.Default()), // without it, request-time warnings are discarded
    op.WithLoginFlow(op.LoginFlow{
        Primary: op.PrimaryPassword{
            Store: users{db},
            // Uncomment when the table holds bcrypt hashes:
            // LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()},
        },
    }),
)
if err != nil {
    log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", handler))

Keep op.WithLogger: request-time warnings, such as the one-time "stored password hash is not argon2id, set PrimaryPassword.LegacySchemes" warning, are discarded without it.

Generate the keys once:

sh
openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out signing.pem
openssl rand -hex 32 > cookie.key

The store behind PrimaryPassword also supplies the ID token and /userinfo claims, so op.WithUserStore is not needed. Required options names what op.New refuses to start without, and 53-production-sql is the runnable version with an existing users table.

The sign-in pages. Without op.WithUI the OP renders its built-in HTML login and consent pages (interaction.HTMLDriver). op.WithUI(op.SPAUI{...}) hands login and consent to your own SPA (SPA / custom interaction), and op.DriverUI{Driver: ...} with your own interaction.Driver serves your own server-rendered pages. op.HTMLUI{Consent: ...} swaps the consent template alone and leaves the login page built in (Custom consent UI).

Inside your existing server. ListenAndServe above runs the OP on its own. In an application that already has a router, mount the handler at the root of that router next to your own routes instead of wrapping it in http.StripPrefix:

go
mux.Handle("/", handler) // your own routes stay registered beside it

The endpoints live under /oidc (op.WithMountPrefix moves them), and the discovery document stays at /.well-known/openid-configuration under the issuer. Behind a reverse proxy, list its CIDRs with op.WithTrustedProxies so the OP sees the client address and scheme. Mount on your router covers chi, gin and path issuers.

Cookies on your host. The OP sets five cookies on the issuer's host. Each is HttpOnly, Secure and Path=/, and carries the __Host- prefix, so the browser sends them to every route of the host, your own pages included, whatever the mount prefix is. None has a Domain, so they never reach a sibling subdomain.

CookieLifetimeHolds
__Host-oidc_sessionup to 14 days idlethe signed-in browser session (encrypted)
__Host-oidc_interaction1 hourthe login or consent in progress (encrypted)
__Host-oidc_csrfbrowser sessionthe double-submit token of the OP's forms
__Host-oidc_logout_csrf5 minutesthe token that confirms RP-initiated logout
__Host-oidc_locale1 yearthe language the user picked on the OP's pages

Your application keeps its own session cookie; these do not replace it, and your middleware should leave the __Host-oidc_ names alone.

5. Register the relying party ​

The OP accepts requests only from clients it knows, so until one is registered /oidc/auth rejects every request. Declare the application that signs users in with op.WithStaticClients in the same option list:

go
op.WithStaticClients(op.ConfidentialClient{
    ID:           "my-app",
    Secret:       secretFromYourSecretStore, // or SecretHash, see below
    AuthMethod:   op.AuthClientSecretBasic,
    RedirectURIs: []string{"https://app.example.com/callback"},
    Scopes:       []string{"openid", "profile", "email"},
}),

Start-up writes a client that is missing from the store, but refuses one whose stored record differs from the definition. An edit (a new redirect URI, a rotated secret) is therefore applied by op.SyncStaticClients(ctx, st, seeds...) from a deploy job before the new version starts. A plaintext Secret shorter than 32 bytes is refused at op.New; op.NewClientSecret() returns a random secret together with its hash. To keep the plaintext out of the configuration, set SecretHash to the hash go run github.com/libraz/go-oidc-provider/cmd/op-client-secret -argon2id prints for it. Client onboarding covers dynamic registration and an admin API.

An older relying party

Two defaults can stop a relying party that predates them. The OP requires PKCE on every authorization-code request, so a confidential client that sends no code_challenge is refused. op.WithProfile(profile.OIDCCore) (github.com/libraz/go-oidc-provider/op/profile) makes PKCE optional for confidential clients. The OP also signs with ES256, so a client that accepts only RS256 ID tokens needs op.WithSigningAlgorithm(op.AlgRS256) together with an RSA key of 2048 bits or more in op.WithKeyset. See Declaring a security profile and Required options.

The OP shows a consent screen before it releases claims to a client. When the relying party is your own application, list its ID with op.WithFirstPartyClients("my-app") to skip that screen. The ID must also be declared in op.WithStaticClients, and a FAPI 2.0 profile refuses the option. First-party consent skip explains the trust decision it encodes.

6. When a user leaves ​

Decide whether you are disabling the user or erasing them.

  • Disable. Make your user store answer store.ErrNotFound for the subject, then call Provider.RevokeSubject(ctx, subject). Login and refresh stop, and every grant of the subject, with its access and refresh tokens, is revoked.
  • Erase. Make your user store answer store.ErrNotFound for the subject, then call Provider.ForgetSubject(ctx, subject). It runs RevokeSubject itself and also deletes the subject's browser sessions, lockout record and second-factor records. It refuses with a configuration_error while any user store still resolves the subject, so the order is enforced.

Your own user row is yours to delete in both cases. Deleted and disabled users lists what each call reaches and what it leaves behind.

7. Test the wiring ​

op/testkit runs a provider on an https httptest server and serves each sign-in step as JSON, so a test can drive the whole login with a stock cookie jar and read each prompt's fields from the response instead of parsing HTML. The built-in HTML pages set __Host- cookies with Secure, which Go's cookie jar does not send over plain http, so a test against an http listener would have to replay them by hand. Pass your own store, login flow and client through testkit.WithOptions; each replaces the matching testkit default:

go
p := testkit.NewProvider(t, testkit.WithOptions(
    op.WithStore(st),
    op.WithLoginFlow(op.LoginFlow{Primary: op.PrimaryPassword{
        Store:         users{db},
        LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()},
    }}),
    op.WithStaticClients(op.ConfidentialClient{ /* as in step 5 */ }),
))
jar, _ := cookiejar.New(nil)
client := p.HTTPClient(jar)

/oidc/auth redirects to /oidc/interaction/{uid}. A GET there returns the prompt with its state_ref and the fields to fill, and sets the __Host-oidc_csrf cookie. Submit the values as JSON, {"state_ref": "...", "values": {...}}, with Origin set to the issuer and X-CSRF-Token echoing the cookie; the cookie is re-issued at each step. Unless the client is in op.WithFirstPartyClients, a consent prompt follows: testkit.IsConsentPrompt detects it, and testkit.ApprovedScopesFrom builds the approved_scopes value that approves every scope it lists. The last response redirects to the client's redirect_uri with the code.

If your relying party needs RS256 ID tokens (the tip in step 5), add testkit.WithSigningAlgorithm(op.AlgRS256) next to testkit.WithOptions so the test signs as production does. p.SignedJWT(claims) signs with the provider's own key, which is how a test builds an id_token_hint for /oidc/end_session; it is not a way to sign a client assertion.