Skip to main content
Version: 2.x (Latest)

Client Registry

Authorizer maintains a client registry — one authoritative table of every OAuth client that can authenticate against the instance. It holds two kinds of clients:

KindWhat it isGrants
interactiveHuman-facing login client (browser/app redirect flows). The instance's reserved client — seeded at boot from --client-id / --client-secret — is this kind.authorization_code, refresh_token
service_accountMachine/workload identity (backend services, CI jobs, AI agents). Created by admins via the admin API.client_credentials, token exchange

kind is immutable after creation. Clients created through the admin API today are service_account clients; the interactive kind is currently reserved for the boot-seeded client, so existing single-client deployments keep working unchanged.

client_id vs. internal id

Every client has two identifiers:

  • id — the internal surrogate primary key (UUID). Used to address the client in admin operations (_update_client, _rotate_client_secret, …).
  • client_id — the public OAuth identifier presented at /authorize and /oauth/token. Unique across the registry and immutable.

For admin-created clients, client_id defaults to the internal id — the UUID returned by _create_client is the value you pass as client_id at the token endpoint. For the reserved boot client, client_id is the literal --client-id value, so every existing token aud, JWKS kid, and introspection client_id keeps keying on that exact string.

Client secrets

  • Generated server-side; stored only as a bcrypt hash (cost 12).
  • The plaintext is returned exactly once — in the _create_client response and again in the _rotate_client_secret response. It can never be retrieved afterwards; store it in your secret manager immediately.
  • Rotating a client's secret does not affect browser session-cookie encryption (that stays bound to the instance's --client-secret).
  • Secret verification is constant-time; an unknown client_id and a wrong secret are indistinguishable to a caller.

Scopes are an allow-list

allowed_scopes is the client's authorization ceiling:

  • A client_credentials request may only ask for a subset of allowed_scopes; anything outside it fails with invalid_scope (no silent downgrade).
  • Omitting scope grants the full authorized set.
  • An empty allow-list is deny-all_create_client requires at least one non-empty scope.
  • In token exchange, allowed_scopes is the agent's delegation ceiling.

Admin API

All operations require super-admin authentication (x-authorizer-admin-secret header or the authorizer.admin cookie from _admin_login) and are also available over gRPC/REST via AuthorizerAdminService (CreateClient, UpdateClient, DeleteClient, RotateClientSecret, GetClient, Clients — see the gRPC reference).

OperationTypePurpose
_create_clientmutationRegister a service account; returns the secret once
_update_clientmutationUpdate name, description, allowed_scopes, is_active
_rotate_client_secretmutationInvalidate the old secret, return a new one once
_delete_clientmutationRemove the client
_clientqueryFetch one client by id
_clientsqueryPaginated list

Create a service account

mutation {
_create_client(
params: {
name: "payments-worker"
description: "Nightly settlement job"
allowed_scopes: ["read:payments", "write:settlements"]
}
) {
client {
id
client_id
name
allowed_scopes
is_active
}
client_secret # shown ONCE — store it now
}
}

Manage clients

# Disable a client (blocks new token issuance immediately;
# already-issued tokens live until their exp)
mutation {
_update_client(params: { id: "CLIENT_UUID", is_active: false }) {
id
is_active
}
}

# Rotate the secret
mutation {
_rotate_client_secret(params: { id: "CLIENT_UUID" }) {
client { id }
client_secret # the new secret, shown ONCE
}
}

# List
query {
_clients {
pagination { total }
clients { id name allowed_scopes is_active created_at }
}
}

Client responses never contain a secret or secret hash — there is no client_secret field on the Client type by design. The Client type does return client_id (the public OAuth identifier, distinct from the internal id) — add it to any of the queries/mutations above when your caller needs the value to present at /oauth/token.

Authenticating: client_credentials

Service accounts get tokens with the RFC 6749 §4.4 grant at POST /oauth/token:

curl -s -X POST $AUTHORIZER_URL/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "scope=read:payments"
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "read:payments"
}

Rules:

  • Client authentication via client_secret_basic (HTTP Basic) or client_secret_post (form body) — never both; presenting more than one method is rejected (RFC 6749 §2.3).
  • The grant is machine-only: an interactive client_id is rejected with unauthorized_client before secret verification, so it can't be used to confirm a guessed secret.
  • No refresh_token, no id_token — re-authenticate on expiry.
  • Discovery (/.well-known/openid-configuration) advertises client_credentials in grant_types_supported.
  • Success and failure are both audited (token.client_credentials / token.client_credentials_failed audit events).
  • A transient registry lookup failure (e.g. a busy/unreachable database) is never reported as invalid_client — that would tell a legitimate caller its credentials are permanently wrong. It returns 503 with temporarily_unavailable instead, and is not counted as a security event.

Service accounts as FGA subjects

A service_account client authenticated via client_credentials can itself be an OpenFGA subject. When your authorization model declares a service_account type, the machine caller's own check_permissions / list_permissions calls resolve to the subject service_account:<client_id> — the client's public client_id, never its internal id:

type service_account

type document
relations
define viewer: [user, service_account]
define can_view: viewer
# Admin: grant the service account viewer on a document, keyed on its client_id
mutation {
_fga_write_tuples(params: {
tuples: [{ user: "service_account:payments-worker-client-id", relation: "viewer", object: "document:1" }]
}) { message }
}
# The service account's own client_credentials token then passes check_permissions
# as service_account:payments-worker-client-id — no explicit "user" needed.

A deactivated (is_active: false) service account is denied even while its already-issued token remains cryptographically valid — FGA is an additional, independent gate. A machine caller may only self-pin its own subject (user: "service_account:<its own client_id>" in check_permissions); pinning any other subject is rejected, exactly like a human caller. See the FGA guide for the full model/tuple/check walkthrough.

Secretless authentication: client assertions & trusted issuers

Instead of a stored secret, a service account can authenticate with a signed JWT it already possesses — a Kubernetes ServiceAccount token or a SPIFFE JWT-SVID — presented as an RFC 7523 client_assertion:

POST /oauth/token
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<external JWT>

The assertion is validated against a trusted issuer the admin registers for that service account (_add_trusted_issuer / _update_trusted_issuer / _delete_trusted_issuer / _trusted_issuer / _trusted_issuers): issuer URL, key source (OIDC discovery or a static JWKS URL), expected audience, and an exact-match subject allow-list (empty = deny-all).

Setup, validation rules, and Kubernetes/SPIFFE walkthroughs: Workload Identity.

Backward compatibility

The registry is additive. On upgrade:

  • The reserved client is seeded idempotently from --client-id / --client-secret (booting twice, or two instances at once, still yields one row).
  • Existing tokens keep validating; aud stays the configured --client-id.
  • Existing session cookies keep decrypting; cookie crypto is not coupled to per-client secrets.
  • Discovery and JWKS shapes are unchanged.