AUTH4D-207 Server clients
Token introspection (AUTH4D-155 (repository file docs/api-spec/auth4d-155.md)) needs a confidential server client. Project owners
and admins now create one through the management API or the console, rotate its secret with an
overlap period, and delete it. auth4.dev generates every secret, returns it once and stores only its
SHA-256 bearer hash (client_secret_hash). A secret can never be read back.
All routes below are management routes: they need a control-plane management token from the
console BFF with the named scope, plus a live project role that grants it. Owners and admins hold
client:write; viewers hold client:read only (403 insufficient_role on writes). Player access
tokens, ID tokens and a server client’s own Basic credentials are not management tokens and get
401. Another developer’s project gets 403 forbidden, as for every project route.
Every write needs Content-Type: application/json and a JSON body ({} is fine), and every
response has Cache-Control: no-store.
Create a server client
POST /management/tenants/{tenantId}/clients (scope client:write)
{ "displayName": "Match server", "clientType": "server", "idempotencyKey": "optional-16-128" }
201:
{
"client": {
"tenantId": "ten_…",
"clientId": "cli_…",
"displayName": "Match server",
"clientType": "server",
"enabled": true,
"redirectUris": ["https://server-client.auth4.invalid/no-redirect"],
"allowedOrigins": [],
"allowedScopes": ["openid"],
"grantTypes": ["authorization_code"],
"tokenEndpointAuthMethod": "client_secret_basic"
},
"clientSecret": "a4cs_<43 base64url characters>"
}
The body matches the existing CreateClientResponseSchema contract, which requires
clientSecret for server clients. Notes:
- Only
displayNameis chosen.redirectUrisorallowedOriginsget400 invalid_redirect_uri,allowedScopesgets400 invalid_scopeandallowBrowserRefreshgets400 browser_refresh_unsupported.tokenEndpointAuthMethod,clientSecret,grantTypes,audience,audiences,tenantIdandclientIdstill get400 reserved_field.400 privileged_client_typeis no longer returned. - The shared client contract requires the
authorization_codegrant and a redirect URI on every non-native client, so server clients get a fixed placeholder on the reserved.invalidTLD. The authorization endpoint refuses every client that is not a browser application, so a server client can never start a sign-in and nothing is redirected there.allowedScopesis["openid"]only to satisfy the contract’s minimum; introspection is not limited by scopes. - A server client uses an application slot like any other enabled client: the plan limit
(
402 plan_limit_exceeded) and the 50-client project limit (409 client_limit_reached) apply. - Repeating a create with the same
idempotencyKeydoes not return the secret again. It gets409 client_secret_not_retrievable, with the existing client ID inerror.details[0].issue; rotate the secret to get a new one. - Audit:
management.client.createdwithclient_type: "server", in the same D1 batch.
GET /management/tenants/{tenantId}/clients lists server clients with the other applications.
Neither the list nor any other response includes a secret or a hash.
Read a server client
GET /management/tenants/{tenantId}/clients/{clientId} (scope client:read) returns the usual
application document plus, for server clients only:
{
"secret_status": {
"rotated_at": "2026-10-10T12:00:00.000Z",
"previous_secret_expires_at": "2026-10-11T12:00:00.000Z"
}
}
rotated_at is null until the first rotation. previous_secret_expires_at is null when no
previous secret is still accepted (never rotated, rotated with no overlap, or the overlap has run
out). The shape is ClientSecretStatusSchema in packages/contracts.
A server client’s logo routes get 403 reserved_client: a server client has no sign-in settings.
Since AUTH4D-305 (repository file docs/api-spec/auth4d-305.md), PUT/PATCH on a server client may change only displayName
and enabled; any other field gets 400 validation_failed.
Rotate the secret
POST /management/tenants/{tenantId}/clients/{clientId}/rotate-secret (scope client:write)
{ "previous_secret_ttl_seconds": 86400 }
| Field | Required | Notes |
|---|---|---|
previous_secret_ttl_seconds |
No | Integer 0–604800 (seven days). Default 86400 (one day). 0 stops it at once. |
200 (RotateClientSecretResponseSchema):
{
"client": { "clientId": "cli_…", "clientType": "server", "…": "…" },
"clientSecret": "a4cs_…",
"secret_status": {
"rotated_at": "2026-10-10T12:00:00.000Z",
"previous_secret_expires_at": "2026-10-11T12:00:00.000Z"
}
}
- The new secret is shown once. The secret that was current keeps authenticating until
previous_secret_expires_at, so a server fleet can switch without downtime. Only one previous secret is kept: a further rotation stops any older secret at once. - Each call issues a new secret, so the route is not idempotent and takes no
idempotencyKey. - Errors:
400 validation_failedfor an out-of-range or non-integer TTL or an unknown field,400 not_a_server_clientfor a browser or native application,404 not_foundfor an unknown client. Other methods are refused. - Audit:
management.client.secret_rotatedwithclient_type: "server"andoverlap_seconds(the TTL; 0 when the old secret stopped at once). Secrets and hashes never enter the audit log.
Client authentication (AUTH4D-155 (repository file docs/api-spec/auth4d-155.md) introspection, and the token and revocation
endpoints) accepts the current secret, or the previous one while it has not expired. The previous
hash is read only when the current one does not match.
Delete a server client
DELETE /management/tenants/{tenantId}/clients/{clientId} (scope client:write)
For a server client this removes the row and its secret hashes for good, so its secrets stop working
at once. 200 (DeleteServerClientResponseSchema):
{ "deleted": true, "client_id": "cli_…" }
A second delete, or any later call for that client, gets 404 not_found. Audit:
management.client.deleted with client_type: "server". For browser and native applications
DELETE still only disables the application (AUTH4D-18 (repository file docs/api-spec/auth4d-18.md)).
Storage
Migration 0032_client_secret_rotation.sql adds three nullable clients columns:
previous_client_secret_hash, previous_client_secret_expires_at (a CHECK keeps it paired with
the hash) and client_secret_rotated_at. It is additive; code reading a database without it treats
every server client as never rotated. Rotation needs the migration.
Console
Applications → Register application → Server (confidential) creates a server client and shows
the secret once, with a copy button and a warning. The server client screen shows the client ID,
when the secret was last rotated and until when the previous one works, a Rotate secret action
with a choice of overlap (one hour, one day, seven days or Stop it now) and a confirmation, and
Delete server client with a confirmation. Viewers see the screen without the actions. Secrets
stay in page memory: never in a URL, browser storage or history state. The BFF forwards
/api/management/tenants/{tenantId}/clients/{clientId}/rotate-secret.
Tests
tests/integration/auth4d-207.server-clients.test.ts: create, introspection with the issued secret, refused fields, idempotent replay, roles, tenant isolation and customer tokens, rotation overlap and expiry, rotation validation, deletion, audit events and a leak scan of every response.tests/e2e/auth4d-207.server-clients.spec.ts(auth4d-207.playwright.config.ts, port 5373): create with the one-time secret, rotate with an overlap and with none, delete, viewer access and 320px layout.
Source: docs/api-spec/auth4d-207.md