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:

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:

  1. Mint a new secret — 256 bits of CSPRNG entropy rendered URL-safe base64 without padding (RandomNumberGenerator.GetBytes(32)).
  2. Persist its hash — SecretHash = AuthoritySecretHasher.ComputeHash(newSecret) (the same deterministic hash the client-credentials handler verifies against). The plaintext is never persisted (ClientSecret stays null).
  3. Grace the outgoing secret — the previous SecretHash is copied to Properties["previousSecretHash"] with Properties["previousSecretExpiresAt"] set to now + graceHours (ISO-8601 round-trip “O”). During that window both the old and the new secret authenticate. graceHours = 0 invalidates the old secret immediately (no grace entry written).
  4. Return the new secret ONCE — the response body carries newSecret, graceHours, previousSecretExpiresAt, and a human-readable message. The secret is never retrievable again; the Console shows it in a dismissible one-time panel.
  5. Auditauthority.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:

  1. checks the current SecretHash (fixed-time compare); if it matches → OK.
  2. else checks previousSecretHash only while now < 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:

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.

  1. 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: 0 for break-glass immediate invalidation).
  2. Copy the new secret from the one-time panel (it is shown once).
  3. 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 / systemd EnvironmentFile update, 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.
  4. 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.
  5. Let the old secret expire (or, once you have confirmed the rollout, rotate again with graceHours: 0 to 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

KnobValueMeaning
Default grace168 hours (7 days)Applied when the request omits graceHours.
Max grace720 hours (30 days)Requests above are clamped.
ImmediategraceHours: 0Old 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