Skip to content

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
  • RFC 7591 — Dynamic Client Registration Protocol
  • RFC 7592 — Dynamic Client Registration Management
  • RFC 6749 §10.1 (client authentication) / §10.6 (CSRF) — security framing
  • RFC 8252 — OAuth 2.0 for Native Apps (redirect URI shape rules)

At a glance — five patterns ​

Five ways in, on one trust spectrum
Five client-onboarding patterns, arranged from fully operator-controlled at one end to nothing trusted on the wire at the other. The middle two are gated by a credential the caller carries; the other three are gated by who can reach the deployment.fully operator-controllednothing trusted on the wireStaticWithStaticClientsgated bythe operator's own configurationaccess to the deploymentIaC / GitOpsstore.ClientRegistrygated bythe operator's CI pipelineaccess to the deploymentDCR/registergated bya minted IAT, then a per-client RATcredential in the requestAdmin APIstore.ClientRegistrygated bya management token: scope plus claimcredential in the requestCLIRegisterClientgated bynothing: it never touches the wireaccess to the deployment
The two middle patterns are the only ones where a stranger can start the conversation, which is why they are the only ones that need a credential in the request. The ends need none because there is no request.
PatternTrust gateWho writes the metadataBest fit
Static registrationOperator configOperator at deploy timeFixed roster of internal apps
IaC / GitOps provisioningOperator's CI pipelinePipeline writes via store.ClientRegistryRP definitions live in source control
Standardised DCR (RFC 7591 + 7592)Operator-minted IAT, per-client RATRP itself, via /registerMulti-tenant SaaS, developer self-serve
Scope-protected admin APIManagement client AT (scope + claim define boundary)Embedder's own HTTP handler → store.ClientRegistryCentral control-plane / IDM GUI
Out-of-band CLI / admin toolOut-of-band auth (none on the wire)Admin binary writes the store directlySRE 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.

go
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:

go
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-sql runs op.SyncStaticClients from a sync-clients command; 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:

  1. Run op.SyncStaticClients with the new seeds as a deploy step, immediately before the rollout.
  2. 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.
  3. To roll back to the old version after a sync, run op.SyncStaticClients with 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:

go
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 */
}
  1. Put the new value in Secret, the value it replaces in PreviousSecret, and the instant the old one stops working in PreviousSecretExpiresAt.
  2. Run op.SyncStaticClients with the new seeds, then start the OP. Before the sync, op.New still refuses the changed seed with ErrConflict, 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 reports updated and a second run reports unchanged.
  3. 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_authorization and /grant_management.
  4. 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 the client_id and the authentication method, and never carries a secret. When it stops arriving for that client, every deployment has moved over.
  5. Remove PreviousSecret and PreviousSecretExpiresAt from 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:

  • PreviousSecret and PreviousSecretHash are mutually exclusive, and either one requires PreviousSecretExpiresAt. 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() seed SecretHash for the new secret and PreviousSecretHash for the old one (from op.NewClientSecret or op.HashHighEntropyClientSecret). op.New refuses a plaintext PreviousSecret there, as it does Secret, because it would be stored as an Argon2id hash. op.SyncStaticClients does not see the option, so if the stored current hash is high-entropy and PreviousSecret does not verify against it, the sync cannot produce a matching hash: it returns an error and writes nothing, and the message tells you to seed PreviousSecretHash.
  • 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.ClientRegistry carry the two fields as PreviousSecretHash and PreviousSecretExpiresAt.

Generating a client secret: op-client-secret ​

sh
go run github.com/libraz/go-oidc-provider/cmd/op-client-secret

With 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>:

sh
printf '%s\n' "$SECRET" | go run github.com/libraz/go-oidc-provider/cmd/op-client-secret -argon2id

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

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

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

go
// 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.ClientRegistry is 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_id and the target client_id. The library's audit.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. RegisterClient does 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 per application_type, jwks / jwks_uri exclusivity, https-only URI fields, sector_identifier_uri containment.
  • Never echo client_secret in 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_uri validation 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.

go
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/clients or 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 with invalid_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 /introspect over 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 — /introspect is the recommended path for admin operations where the cost of a misuse is high enough that the extra round-trip is cheap insurance.