Client secret rotation & distribution
Status: implemented (mint / persist / one-time-return + dual-secret grace window). Owner: Authority. Last reviewed: 2026-07-18.
This document is the authoritative design for OAuth2 client secret rotation in Authority and, in particular, for the hard problem of distribution: how a freshly-rotated secret actually reaches a service client that reads its secret from static deploy config, without an authentication outage and without Authority pushing secrets into operator-controlled infrastructure.
Related code:
- Endpoint:
POST /console/admin/clients/{clientId}/rotate→ConsoleAdminEndpointExtensions.RotateClient(src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs). - Grace-window verification:
ClientCredentialHandlerHelpers.VerifySecretWithGraceused byValidateClientCredentialsHandler(src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs). - Metadata keys:
AuthorityClientMetadataKeys.PreviousSecretHash/PreviousSecretExpiresAt(src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthorityClientMetadataKeys.cs). - Console UI:
clients-list.component.ts(rotate action + one-time secret panel).
1. What rotation does
POST /console/admin/clients/{clientId}/rotate (scope authority:clients.write, fresh-auth required) performs, atomically from the operator’s point of view:
- Mint a new secret — 256 bits of CSPRNG entropy rendered URL-safe base64 without padding (
RandomNumberGenerator.GetBytes(32)). - Persist its hash —
SecretHash = AuthoritySecretHasher.ComputeHash(newSecret)(the same deterministic hash the client-credentials handler verifies against). The plaintext is never persisted (ClientSecretstays null). - Grace the outgoing secret — the previous
SecretHashis copied toProperties["previousSecretHash"]withProperties["previousSecretExpiresAt"]set tonow + graceHours(ISO-8601 round-trip “O”). During that window both the old and the new secret authenticate.graceHours = 0invalidates the old secret immediately (no grace entry written). - Return the new secret ONCE — the response body carries
newSecret,graceHours,previousSecretExpiresAt, and a human-readablemessage. The secret is never retrievable again; the Console shows it in a dismissible one-time panel. - Audit —
authority.admin.clients.rotate(success) records the client id, grace hours, and previous-secret expiry (never the secret value).
Rotation is only valid for confidential clients (those that authenticate with a shared secret). Public / certificate-bound clients return 400 client_secret_not_supported. Unknown clients return 404 client_not_found.
Response shape:
{
"clientId": "acme-release-dispatch",
"newSecret": "u1r7…redacted…",
"graceHours": 168,
"previousSecretExpiresAt": "2026-07-25T09:00:00.0000000+00:00",
"message": "Client secret rotated. Copy the new secret now — it will not be shown again. The previous secret keeps working until 2026-07-25T09:00:00.0000000+00:00 so you can roll it out; after that it stops."
}
2. The dual-secret grace window (why distribution is now safe)
Service clients (stellaops-*-service, and operator-created machine clients) read their secret from static deploy config — environment variables, compose .env, a mounted secret file, or a secret store the operator controls. Authority has no channel to push a new secret into that config, and — on an on-prem / air-gapped, sovereign-first platform — it must not: pushing secrets into customer-controlled infrastructure (or a customer Vault) would violate the trust boundary and the offline posture.
The grace window is the mechanism that turns “rotate” from a guaranteed outage into a safe, operator-paced rollover:
t0 rotate t0 + graceHours
│ old secret valid │========= BOTH old + new valid =========│ only new valid
└──────────────────┴────────────────────────────────────────┴───────────────▶
▲ operator updates deploy config ▲ old secret dies
somewhere in this window
At token time, ValidateClientCredentialsHandler calls VerifySecretWithGrace(presentedSecret, document, now), which:
- checks the current
SecretHash(fixed-time compare); if it matches → OK. - else checks
previousSecretHashonly whilenow < previousSecretExpiresAt→ OK during the window, rejected after.
Because the grace state lives in the client document’s Properties (a JSONB column that round-trips through Postgres), the window is honored by the live auth path with no new schema and no new service — it converges on any fresh database with no manual step (repo rule §2.7 is satisfied; nothing to migrate).
Safeguards:
- Expiry is exclusive — at the exact
previousSecretExpiresAtinstant the old secret is already dead. - A manual secret change via
PATCH /clients/{id}(settingclientSecret, or togglingrequireClientSecret) clears any in-flight grace entry, so a superseded secret can never linger past an explicit reset. - The stale grace entry after expiry is inert (the expiry check fails closed); the next rotation overwrites it.
3. Distribution model (operator rollout)
This is the concrete, supported path for getting a rotated secret into a running service client without downtime. It is deliberately operator-driven, not Authority-push, for the trust-boundary reasons above.
- Rotate from the Console (Console-admin → Clients → Rotate Secret) or via the API. Choose the grace window long enough to cover your rollout (default 7 days, max 30 days;
graceHours: 0for break-glass immediate invalidation). - Copy the new secret from the one-time panel (it is shown once).
- Distribute the new secret to the client’s deploy config using your existing secret-management path — whichever the operator already owns:
- a compose
.env/ Kubernetes Secret / systemdEnvironmentFileupdate, then restart the service; or - write it to the operator’s HashiCorp Vault path that the service reads at start (Vault is the flagship secret backend — the service reads Vault, Authority does not write it); or
- your config-management tool (Ansible/etc.) templating the value out.
- a compose
- Verify the service authenticates with the new secret (a token request succeeds) before the grace window ends. Both secrets are valid meanwhile, so there is no window where the service cannot get a token.
- Let the old secret expire (or, once you have confirmed the rollout, rotate again with
graceHours: 0to retire it immediately).
Seeded platform clients (stellaops-*-service, stella-ops-ui, console-workspace) are shown read-only in the Console (System badge): their secrets are part of the deployment bootstrap, so they are rotated deliberately via the API/CLI plus the deploy-config update above, not casually from the clients table.
4. Configuration
| Knob | Value | Meaning |
|---|---|---|
| Default grace | 168 hours (7 days) | Applied when the request omits graceHours. |
| Max grace | 720 hours (30 days) | Requests above are clamped. |
| Immediate | graceHours: 0 | Old secret invalidated at once (break-glass). |
DefaultSecretRotationGraceHours / MaxSecretRotationGraceHours are constants in ConsoleAdminEndpointExtensions. If a per-tenant/config-driven window is ever required, promote them to StellaOpsAuthorityOptions and thread the value through RotateClient — the metadata contract does not change.
5. Tier-2 / live verification (owed)
The unit + endpoint tests prove mint/persist/one-time-return and the grace-window verification logic deterministically. The live forcing-function still owed (needs the running stack): rotate a real confidential service client, then obtain a token from /connect/token with (a) the new secret → 200, (b) the old secret within the window → 200, © the old secret after expiry → 401 invalid_client. Capture the three responses as the rollover proof.
6. Future work
- CLI:
stella authority client rotate <clientId> [--grace-hours N]so the rollout can be scripted/air-gapped without the Console. - Optional Vault-backed distribution: an opt-in mode where, if the operator has configured a Vault write path for a client, Authority writes the new secret to that path during rotation (still operator-owned infrastructure, still no cloud-managed default). This stays a plugin/opt-in — never a default DI stub.
