Use case — Client onboarding patterns
"Creating an OAuth/OIDC client" has at least five operational shapes, and only one of them is specified by an IETF / OIDF document. This page is the map: when each pattern fits, the library seam it lands on, and the security envelope that comes with it. The standardised path is Dynamic Client Registration; the other four are operator design space, and the boundary between "library responsibility" and "embedder responsibility" is sharper than it looks.
Specs referenced on this page
At a glance — five patterns
| Pattern | Trust gate | Who writes the metadata | Best fit |
|---|---|---|---|
| Static registration | Operator config | Operator at deploy time | Fixed roster of internal apps |
| IaC / GitOps provisioning | Operator's CI pipeline | Pipeline writes via store.ClientRegistry | RP definitions live in source control |
| Standardised DCR (RFC 7591 + 7592) | Operator-minted IAT, per-client RAT | RP itself, via /register | Multi-tenant SaaS, developer self-serve |
| Scope-protected admin API | Management client AT (scope + claim define boundary) | Embedder's own HTTP handler → store.ClientRegistry | Central control-plane / IDM GUI |
| Out-of-band CLI / admin tool | Out-of-band auth (none on the wire) | Admin binary writes the store directly | SRE batch import, recovery |
If you only care about standardised flows, stop after row 3. Rows 4 and 5 are vendor design space — the library exposes the seams but does not ship the surface, because the security envelope is application-specific.
1. Static registration
The simplest shape, and the one most embedders start with. op.WithStaticClients accepts a variadic list of op.ClientSeed builders; each builder projects onto a store.Client record at construction time, and the records are pinned with Source: ClientSourceStatic so they are eligible for first-party auto-consent.
pubJWKS, err := op.LoadPublicJWKS("conformance/keys/fapi-client.jwks.json")
if err != nil { /* ... */ }
provider, err := op.New(
/* required options */
op.WithStaticClients(
op.ConfidentialClient{
ID: "billing-app",
Secret: "billing-app-secret-at-least-32-bytes-0123", // 32 bytes or more
AuthMethod: op.AuthClientSecretBasic,
RedirectURIs: []string{"https://billing.example.com/callback"},
Scopes: []string{"openid", "profile"},
GrantTypes: []string{"authorization_code", "refresh_token"},
},
op.PublicClient{
ID: "billing-spa",
RedirectURIs: []string{"https://app.example.com/callback"},
Scopes: []string{"openid", "profile"},
},
op.PrivateKeyJWTClient{
ID: "fapi-rp",
JWKS: pubJWKS,
RedirectURIs: []string{"https://rp.example.com/callback"},
Scopes: []string{"openid"},
},
),
)op.PrivateKeyJWTClient takes either inline JWKS or a JWKSURI (exactly one). For mutual-TLS client authentication (feature.MTLS), op.TLSClientAuthClient carries exactly one certificate matcher (SubjectDN, SANDNS, SANURI, SANIP or SANEmail) and op.SelfSignedTLSClientAuthClient carries exactly one of JWKS and JWKSURI.
The seed projection enforces the same redirect-URI shape rules at op.New time that DCR enforces at POST /register, so a malformed seed fails the constructor instead of leaking through to runtime. ConfidentialClient.Secret is hashed via op.HashClientSecret (argon2id) before the record reaches the store; the plaintext never persists. A client_secret_basic or client_secret_post client must carry either Secret or a pre-computed SecretHash (not both); op.New refuses one with neither, because an empty secret would otherwise authenticate an empty client_secret. A plaintext Secret shorter than 32 bytes is refused too (OpenID Connect Core §16.19); op.NewClientSecret mints a secret and hash that satisfy it. Under op.WithHighEntropyClientSecrets() provision SecretHash from op.NewClientSecret or op.HashHighEntropyClientSecret, since Secret always hashes with argon2id.
Against a persistent backend the seeds are applied as one atomic, idempotent batch through store.StaticClientReconciler: missing records are inserted, records already equivalent are left alone, and a record that diverges from a stored one — or collides with a dynamically registered client — fails with ErrConflict. The reconciliation runs after every other fallible build step, so a boot that fails for an unrelated reason never leaves half the roster written. Restarting an OP with an unchanged seed list is therefore a no-op rather than a rewrite. The reconciliation never deletes a stored static client that has dropped out of the list.
Changing a static client: op.SyncStaticClients
Because start-up refuses a seed that differs from the stored record, editing a static client (a new redirect URI, a rotated secret) needs an explicit step. Run op.SyncStaticClients from a deploy job or an admin command before op.New, with the same seeds the provider is built with:
results, err := op.SyncStaticClients(ctx, storage, staticClients()...)
if err != nil { /* ... */ }
for _, r := range results {
log.Printf("client %s: %s", r.ID, r.Action) // inserted, updated, or unchanged
}A missing client is inserted, a static client whose metadata or secret differs is updated, and an identical one is left alone. A stored client whose source is not static (dynamically registered or admin-created) is never touched: if any seed meets one, the call returns an error wrapping store.ErrConflict and writes nothing. The writes are not atomic, but a failure part-way leaves a state that running the call again converges. SyncStaticClients never deletes; retire a client dropped from the list with ClientRegistry.DeleteClient (pattern 2 or 5). Never expose it on an HTTP route: it rewrites client credentials. The store must expose a writable client registry, otherwise the call fails with a configuration error. After the sync, the op.New reconcile finds nothing to change.
Source:
examples/53-production-sqlrunsop.SyncStaticClientsfrom async-clientscommand; without the sync, start-up refuses an edited client.
Rolling deploys
The comparison runs in every op.New, in both directions. Once a sync has written changed metadata or a new secret, a replica that starts with the old seeds fails op.New with a configuration conflict (ErrConflict); a replica that starts with the new seeds before the sync fails the same way. Replicas that are already running are unaffected, because the check happens only at construction. The sync therefore opens a window in which an old-version replica cannot restart:
- Run
op.SyncStaticClientswith the new seeds as a deploy step, immediately before the rollout. - Roll out the new version. Keep old-version replicas running until they are replaced, and do not restart or scale out from the old image in between; a restart there fails to start.
- To roll back to the old version after a sync, run
op.SyncStaticClientswith the old seeds first.
A deploy that leaves the seeds unchanged needs no sync. See Multi-instance deployment.
Rotating a secret without downtime
Replacing Secret alone breaks every deployment of the client that has not picked up the new value yet. A confidential seed can instead hold one previous secret next to the current one, with a deadline:
op.ConfidentialClient{
ID: "billing-app",
Secret: newSecret,
PreviousSecret: oldSecret,
PreviousSecretExpiresAt: time.Now().Add(6 * time.Hour),
AuthMethod: op.AuthClientSecretBasic,
/* redirect URIs, scopes, grants as before */
}- Put the new value in
Secret, the value it replaces inPreviousSecret, and the instant the old one stops working inPreviousSecretExpiresAt. - Run
op.SyncStaticClientswith the new seeds, then start the OP. Before the sync,op.Newstill refuses the changed seed withErrConflict, as for any other edit. The sync stores a hash for each secret: an old plaintext that verifies against the stored current hash (the first rotation) or the stored previous hash (a re-run) keeps that hash. The first run therefore reportsupdatedand a second run reportsunchanged. - Give the client the new secret. Until the deadline, both secrets authenticate at every endpoint that authenticates the client:
/token,/par,/introspect,/revoke,/bc-authorize,/device_authorizationand/grant_management. - Watch the audit stream for
client_authn.previous_secret(audit.ClientAuthnPreviousSecret). It fires at warn level each time a client authenticates with the previous secret, carries theclient_idand the authenticationmethod, and never carries a secret. When it stops arriving for that client, every deployment has moved over. - Remove
PreviousSecretandPreviousSecretExpiresAtfrom the seed and run the sync again, so the old hash leaves the store. Once the deadline passes the old secret no longer authenticates, even if you forget this step.
The rules behind it:
PreviousSecretandPreviousSecretHashare mutually exclusive, and either one requiresPreviousSecretExpiresAt. A previous secret without a deadline is never accepted. A deadline in the past is not an error: the previous secret is simply ignored.- Keep the window to hours, not weeks. While a previous secret is live, a wrong secret costs two Argon2id verifications instead of one, so a guessing flood against that client costs the OP twice as much. A short window also shortens the time a leaked old secret stays usable.
- High-entropy secrets. Under
op.WithHighEntropyClientSecrets()seedSecretHashfor the new secret andPreviousSecretHashfor the old one (fromop.NewClientSecretorop.HashHighEntropyClientSecret).op.Newrefuses a plaintextPreviousSecretthere, as it doesSecret, because it would be stored as an Argon2id hash.op.SyncStaticClientsdoes not see the option, so if the stored current hash is high-entropy andPreviousSecretdoes not verify against it, the sync cannot produce a matching hash: it returns an error and writes nothing, and the message tells you to seedPreviousSecretHash. - Dynamic clients cannot rotate this way, because RFC 7592 defines no way to hold two secrets. An RFC 7592 update that clears or replaces the secret also drops the previous secret, and one that keeps the secret keeps it. Records written through
store.ClientRegistrycarry the two fields asPreviousSecretHashandPreviousSecretExpiresAt.
Generating a client secret: op-client-secret
go run github.com/libraz/go-oidc-provider/cmd/op-client-secretWith no arguments it mints a new high-entropy secret and prints two lines, secret: <plaintext> and hash: <hash>. Give the plaintext to the client, which is the only copy, and put the hash in ConfidentialClient.SecretHash; this is the form op.WithHighEntropyClientSecrets() requires. With -argon2id it reads one plaintext line from standard input and prints hash: <argon2id hash>:
printf '%s\n' "$SECRET" | go run github.com/libraz/go-oidc-provider/cmd/op-client-secret -argon2idA plaintext ConfidentialClient.Secret is always stored as an argon2id hash, so under op.WithHighEntropyClientSecrets() the seed must use SecretHash, and op.SyncStaticClients does not see that option: syncing a plaintext secret there is rejected later by op.New.
This is the right pattern when the RP roster is short, owned by the same team that runs the OP, and changes through the same deployment pipeline as any other config.
2. IaC / GitOps provisioning
When the RP roster lives in source control — a Terraform / Pulumi module, a Kustomize overlay, a Helm values file — you want a provisioning binary that reads the manifest and writes the OP store directly. The library exposes the write path through store.ClientRegistry; any backend that supports DCR satisfies it.
storage, err := oidcsql.New(db, oidcsql.Postgres())
if err != nil { /* ... */ }
registry, ok := storage.Clients().(store.ClientRegistry)
if !ok {
return fmt.Errorf("backend does not support client writes")
}
for _, plan := range desired {
hash, err := op.HashClientSecret(plan.Secret)
if err != nil { /* ... */ }
c := &store.Client{
ID: plan.ID,
RedirectURIs: plan.RedirectURIs,
Scopes: plan.Scopes,
GrantTypes: plan.GrantTypes,
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: op.AuthClientSecretBasic.String(),
SecretHash: hash,
Source: store.ClientSourceAdmin,
}
if err := registry.RegisterClient(ctx, c); errors.Is(err, store.ErrAlreadyExists) {
err = registry.UpdateClient(ctx, c)
}
if err != nil { /* ... */ }
}store.ClientSourceAdmin is the right discriminator here: it leaves the record eligible for first-party auto-consent (same as ClientSourceStatic) but is distinguishable in audit-log analysis from records the OP itself created via /register. The provisioning binary is responsible for shape validation before the write — RegisterClient is a raw store write and does not run the registration handler's rule set. Do not write a record that carries both jwks and jwks_uri: the OP refuses it at use time on every surface that reads client keys (request objects, private_key_jwt, response encryption). If the OP holds a long-running in-memory client cache, plan for invalidation (a SIGHUP-driven reload, a small admin endpoint your binary calls, or the natural cache expiry your backend exposes).
Treat the secret material in the manifest the way you treat any other deploy-time credential: encrypted state, narrow read scope on the CI principal, no echo in CI logs.
3. Standardised DCR (RFC 7591 + 7592)
When the RP is on the other side of an organisational boundary — a tenant in a multi-tenant SaaS, a partner in an integration marketplace, a developer signing up to your platform — you want a JSON API the RP can call itself. RFC 7591 / RFC 7592 specify exactly that surface, and op.WithDynamicRegistration mounts it.
provider, err := op.New(
/* required options */
op.WithDynamicRegistration(op.DynamicRegistration{
AllowedGrantTypes: []string{"authorization_code", "refresh_token"},
AllowedResponseTypes: []string{"code"},
}),
)
iat, err := provider.IssueInitialAccessToken(ctx, op.InitialAccessTokenSpec{
TTL: 1 * time.Hour,
MaxUses: 1,
})Hand iat.Value to the RP out-of-band. Keep the TTL short and MaxUses: 1 so a leaked IAT cannot be replayed. The RP receives a registration_access_token (RAT) in the 201 response and uses it for RFC 7592 read / update / delete operations on its own registration only — RFC 7592 §2 is explicit that a RAT for client A must not authorise operations on client B. See Dynamic Client Registration for the full surface, including the safety-floor rules the validator enforces and the metadata fields that round-trip.
4. Scope-protected admin API
This is the pattern most vendor SaaS providers ship as "the admin API", and the one the library deliberately does not ship. The shape is well-known: a privileged "management client" obtains an access token bearing scopes such as client.read / client.write (and optionally a tenant=acme claim), and the embedder's own HTTP handler verifies the AT, decodes the scope and tenant boundary, and calls store.ClientRegistry.
The management client is confidential because its handler calls /introspect. A minimal M2M provider enables the client-credentials grant with op.WithGrants(grant.ClientCredentials) and does not configure a browser LoginFlow; a browser-capable provider instead wires its authorization-code grants and login flow explicitly. /introspect authenticates this client with its own credentials by default. If the handler must inspect an access token issued to another client, add the management client to that resource's ProtectedResource.IntrospectionClients; refresh tokens are never delegated across clients.
// 1. Static seed for the management client.
op.WithStaticClients(
op.ConfidentialClient{
ID: "control-plane",
Secret: "control-plane-secret-at-least-32-bytes-0123", // 32 bytes or more
AuthMethod: op.AuthClientSecretBasic,
GrantTypes: []string{"client_credentials"},
Scopes: []string{"client.read", "client.write"},
},
)
// 2. Embedder-owned admin handler (sketch).
func adminCreateClient(registry store.ClientRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
intro, err := postIntrospect(r.Context(), bearer) // your HTTP call to /introspect
if err != nil || !intro.Active || !hasScope(intro.Scope, "client.write") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var c store.Client
if err := json.NewDecoder(r.Body).Decode(&c); err != nil { /* 400 */ }
if err := embedderValidate(&c, intro); err != nil { /* 400 */ } // see warnings below
c.Source = store.ClientSourceAdmin
if err := registry.RegisterClient(r.Context(), &c); err != nil { /* map error */ }
// emit your own audit event here — the library's `audit.DCR*` events do not fire on this path.
w.WriteHeader(http.StatusCreated)
}
}The library mounts no such handler. Whether it lives on the public OP host, a separate admin host, or only inside the cluster network is a deployment choice; none of those choices is the security boundary. The boundary is the access token your handler validates before touching the store, which means you are signing up to enforce the rules below.
Security envelope of pattern 4
- Compromise of a management AT equals takeover of every client in its scope. Keep the AT TTL short, and require sender-constraint (DPoP or mTLS) on the management client so a stolen bearer cannot be replayed off-host.
- Privilege-escalation guard. The handler MUST refuse metadata that would create or upgrade a client into grant types, scopes, or FAPI capabilities the calling AT does not itself possess.
store.ClientRegistryis a raw write — it does not perform this check, and skipping it is the canonical way these APIs become CVE-bearing. - Tenant boundary. If you have multi-tenancy, it is enforced only by your handler reading the calling AT's tenant claim and filtering reads / scoping writes accordingly. The library has no tenant concept.
- Audit logging. Every CRUD operation MUST be logged with the calling
client_idand the targetclient_id. The library'saudit.DCR*events fire only for/register(the path the OP itself owns); your admin API is invisible to that catalogue. - Validate
redirect_uris(and friends) yourself.RegisterClientdoes not run the RFC 8252 / OIDC Registration §2 rules that DCR's handler runs. Mirror the rule list from Dynamic Client Registration → Safety floors and What registration enforces today: redirect-URI shape perapplication_type,jwks/jwks_uriexclusivity, https-only URI fields,sector_identifier_uricontainment. - Never echo
client_secretin audit logs. Hash before logging; treat the plaintext the way RFC 7591 §3.2.1 treats it — one-shot material returned exactly once. - Rate-limit the endpoint. JWKS fetches and
sector_identifier_urivalidation cost real I/O, and an unbounded admin endpoint is a useful DoS amplifier.
5. Out-of-band CLI / admin tool
When pattern 4 is too much surface — an SRE batch import, a recovery flow, a local migration script — a binary that opens the store directly and calls RegisterClient is the lowest-overhead option.
storage, err := oidcsql.New(db, oidcsql.Postgres())
if err != nil { /* ... */ }
registry := storage.Clients().(store.ClientRegistry)
c := &store.Client{
ID: "support-tool",
RedirectURIs: []string{"https://support.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: op.AuthNone.String(),
PublicClient: true,
Source: store.ClientSourceAdmin,
}
if err := registry.RegisterClient(ctx, c); err != nil { /* ... */ }This bypasses every library-side handler — the validation that lives next to /register does not run, because the request never traverses the HTTP surface. The binary therefore has to mirror the same shape rules locally before the write: redirect-URI form per application_type, jwks / jwks_uri exclusivity, https-only URI fields, no fragments, no wildcard hosts. Audit is the operator's responsibility (the binary's own log, the OS audit subsystem, or a structured event written to the same sink the OP uses). If the OP holds an in-memory client cache, plan for invalidation the same way pattern 2 does.
What the library deliberately does NOT ship
- No scope-protected admin endpoint at
/admin/clientsor similar. Vendor SaaS providers tend to ship one; this library does not. There is no IETF / OIDF spec for that surface, and the security envelope (privilege escalation, tenant boundaries, audit) is application-specific. A generic implementation would push embedders to under-customise the security layer. software_statement(RFC 7591 §2.3) is not accepted. A registration request that includes the field is rejected withinvalid_software_statement. Federation / signed-statement trust chains are out of scope today.- OpenID Federation 1.0 trust chains are not implemented.
- No programmatic
Provider.IntrospectAccessToken(ctx, token)method. Embedders verifying ATs from their own admin handler call/introspectover HTTP and parse the JSON response. For JWT-format ATs the embedder can verify against the OP's JWKS in-process, but doing so loses revocation visibility —/introspectis the recommended path for admin operations where the cost of a misuse is high enough that the extra round-trip is cheap insurance.
Read next
- Dynamic Client Registration — RFC 7591 / 7592 detail, including the safety-floor rules pattern 4 and pattern 5 must mirror.
- Public / internal scopes — designing the scope vocabulary your management client uses.
- Design judgments — why specific defaults (e.g.
software_statementrejection) are deliberate.