Client properties
The complete property list for an oauth_clients row. Ground truth is
migration 025
028for the schema andinternal/auth/oidc/store.gofor the behaviour.
For the m2m registry, see m2m_clients at the bottom.
oauth_clients
Identity
| Property | Type | What it does | Default |
|---|---|---|---|
client_id | TEXT NOT NULL UNIQUE | The public name of the application. Sent on every authorize and token request, and lands in the id_token’s aud and azp. | none — required |
name | TEXT NOT NULL | Shown to the user on the consent screen and in the browser’s FedCM chooser. | none — required |
description | TEXT | Free text for the admin UI. Not shown to the user. | NULL |
logo_url | TEXT | Rendered on the consent screen. | NULL |
Enforced: client_id uniqueness by the database; a duplicate on create
returns 400 “That client_id is already registered”.
Gotcha — who picks the id. An admin chooses client_id. Self-service
cannot: MintClientID derives it from the name plus 6 random bytes
(my-app-3f9c1d2b04ae). A caller-chosen id is a squatting and impersonation
surface — civicgate-official, or simply grabbing an id an internal client
was going to want — and the id is not a secret, so nothing is gained by
letting a registrant pick it.
Gotcha — logo_url must be https://. Self-service registration
refuses anything else (validateLogoURL), which keeps an
attacker-chosen image off a plain-http origin and keeps a javascript: or
data: URL out of an <img src> on our origin. The admin path does not
apply this check.
Credentials
| Property | Type | What it does | Default |
|---|---|---|---|
client_secret_hash | TEXT | bcrypt hash of the secret. NULL/empty means the client is public. | NULL |
client_secret_prefix | TEXT | First 12 characters of the secret, so a UI can say which secret is live. | NULL |
Enforced: Client.IsPublic() is !hash.Valid || hash == "". A
confidential client must present its secret at the token endpoint
(client_secret_post or client_secret_basic; Basic wins when both are
sent). A public client presents none, and PKCE takes the secret’s place.
Format: cgs_ + 32 random bytes, base64url. The 12-character prefix
leaves roughly 208 bits unguessed, so displaying it identifies the secret
without meaningfully helping anyone reproduce it.
Redirects
| Property | Type | What it does | Default |
|---|---|---|---|
redirect_uris | TEXT[] NOT NULL | The exact-match allow-list of addresses an authorization code may be delivered to. Also the source of the FedCM origin allow-list. | '{}' |
post_logout_uris | TEXT[] NOT NULL | Acceptable post_logout_redirect_uri values at /oauth/logout. Also counts toward allowed origins. | '{}' |
Enforced: Client.AllowsRedirect at /oauth/authorize (before any
redirect happens) and again at /oauth/token against the URI the code was
issued for. EndSession matches post_logout_uris the same way.
Registration-time validation is
oidc.ValidateRedirectURIs,
shared by the admin and self-service paths.
Gotcha — at least one is required. Both create paths refuse an empty
list rather than storing a client that cannot complete a single login.
post_logout_uris may be empty; post-logout redirection is optional.
Gotcha — max 10 each (oidc.MaxRedirectURIs), 512 characters per entry.
Full rules: Redirect URIs.
Capabilities
| Property | Type | What it does | Default |
|---|---|---|---|
allowed_scopes | TEXT[] NOT NULL | The ceiling on what this client may ever obtain. | '{openid,profile,email}' |
grant_types | TEXT[] NOT NULL | Which OAuth grants this client may use. | '{authorization_code,refresh_token}' |
allowed_scopes is enforced by Client.FilterScopes, which intersects
the request with this list at /oauth/authorize, so a compromised client
cannot escalate its own access by simply asking for more. The scope in
the token response is the granted set, which may be narrower than what
was requested — read it rather than assuming.
Gotcha: admin create force-prepends openid if you leave it out, and
substitutes ["openid","profile","email"] for an empty list. Self-service
rejects an out-of-range scope outright rather than silently dropping it —
a silent drop leaves a developer with a client that authorises fine and
then returns a token missing the claim they asked for, with nothing
anywhere explaining why.
grant_types is enforced — this is new. See below.
grant_types, in detail
Checked by Client.AllowsGrant(grant) at three call sites:
| Where | Effect when the grant is not registered |
|---|---|
/oauth/authorize | 302 …?error=unauthorized_client to the (already validated) redirect URI |
/oauth/token, authorization_code | 400 unauthorized_client |
/oauth/token, refresh_token | 400 unauthorized_client |
/oauth/token, client_credentials | 400 unauthorized_client |
Authorize is checked as well as token deliberately: checking only at the token endpoint would walk a person through a full consent screen before refusing a client that was knowably ineligible before anyone was asked to approve anything.
Accepted values are exactly oidc.SupportedGrants:
authorization_code, refresh_token, client_credentials. Both admin
create and admin PATCH validate against that list and refuse anything
else with 400 Unsupported grant type: <value>. Discovery’s
grant_types_supported is derived from the same constant, so the document
cannot advertise something the endpoint does not serve.
Who may set what:
| Path | grant_types |
|---|---|
POST /admin/oauth/clients | Chosen; validated; client_credentials refused when public: true |
PATCH /admin/oauth/clients/{id} | Chosen; validated (see the gotcha below) |
POST /oidc/clients | Fixed to authorization_code + refresh_token. Not caller-settable. |
PATCH /oidc/clients/{id} | Not readable from the body and not in the SET clause — belt and braces |
Fixed. PATCH now loads the client and refuses client_credentials on a public one, exactly as create does. It previously could not tell, because it never loaded the row.
Admin create refuses client_credentials on a public: true client.
Admin PATCH validates only that each value is a supported grant — it
does not load the client, so it cannot tell whether the row is public, and
it will store the combination.
This is not exploitable: tokenByClientCredentials refuses a public
client at request time with
invalid_client — client_credentials requires a confidential client with a secret, before it even consults AllowsGrant. But the stored row will
claim a capability the client does not have, so do not rely on the column
alone to tell you what a public client can do.
:::
Trust and binding
| Property | Type | What it does | Default |
|---|---|---|---|
trusted | BOOLEAN NOT NULL | Skips the consent screen entirely. | FALSE |
require_pkce | BOOLEAN NOT NULL | Forces PKCE even for a confidential client. | TRUE |
status | TEXT NOT NULL | active | anything else. | 'active' |
app_code | TEXT | Names an apps row; scopes the issued access token. | NULL |
owner_user_id | UUID REFERENCES users(id) ON DELETE CASCADE | Self-service owner. NULL = administrator-created. | NULL |
created_at / updated_at | TIMESTAMPTZ | Bookkeeping. | now() |
trusted is enforced at /oauth/authorize: if !client.Trusted gates
the whole consent block. Only for first-party clients you operate
yourself. It is never settable through self-service.
require_pkce is enforced at both authorize and token. The effective
rule is client.RequirePKCE || client.IsPublic() — a public client always
needs PKCE whatever the column says. When required, code_challenge is
mandatory and code_challenge_method must be S256; plain is
refused outright at authorize even though the RFC permits it, because it
offers no protection against an attacker who saw the challenge.
status is enforced by the runtime lookup only:
SELECT … FROM oauth_clients WHERE client_id = $1 AND status = 'active'Any value other than active makes the client invisible to
/oauth/authorize, /oauth/token, /oauth/logout and FedCM — the error
is Unknown or inactive client, deliberately not distinguishing the two.
GetClientByOwner omits that filter on purpose: a disabled client must
still appear on the screen that could re-enable it. Self-service PATCH
restricts the value to active or disabled; admin PATCH accepts any
string.
owner_user_id is enforced inside the WHERE clause of every
self-service statement, never by a fetch-then-compare. ON DELETE CASCADE
is deliberate: the account goes, its applications go with it, because an
orphaned client is a live credential nobody can see in a UI to revoke.
app_code is the property most often misread — it does not decide
which users may sign in. See
Users, apps and tenancy.
Field-name mapping
The JSON body, the DTO and the column all use the same names, with two exceptions worth knowing:
| Request field | Column | Note |
|---|---|---|
public | (none) | A create-time flag, not a column. true → no secret is generated, so client_secret_hash stays NULL, which is what IsPublic() reads. You cannot PATCH a client between public and confidential; rotate-secret always writes a hash. |
| (none) | client_secret_hash | Never returned. The DTO exposes has_secret and is_public booleans instead. |
m2m_clients
A different table with a different shape, for machine callers. Created by
POST /admin/m2m-clients (system_admin only), listed and revoked
through the same prefix. There is no self-service path and no UI.
| Property | Type | What it does | Default |
|---|---|---|---|
client_id | TEXT NOT NULL UNIQUE | Operator-chosen, e.g. rm-prod. Easier to read in logs than a UUID. | none — required |
client_secret_hash | TEXT NOT NULL | bcrypt. Plaintext returned once on create. Never optional — there is no public m2m client. | none |
name / description | TEXT | Bookkeeping. name rides in the token’s service_name. | |
scopes | TEXT[] NOT NULL | Permission strings this client may assert. The issued token’s scopes are the intersection with any requested scope. Empty = the client’s full grant. | '{}' |
allowed_audiences | TEXT[] NOT NULL | Optional restriction on target audiences. Empty = the server’s default audience. | '{}' |
status | TEXT NOT NULL CHECK (active|disabled) | Disabled clients fail the grant without leaking whether the secret was right. | 'active' |
last_used_at | TIMESTAMPTZ | Stamped on every successful grant — the “what is still consuming this?” check before revoking. | NULL |
revoked_at | TIMESTAMPTZ | Soft revoke. Filtered out of the hot-path index. | NULL |
created_by | UUID REFERENCES users(id) ON DELETE SET NULL | Which admin minted it. | NULL |
Revocation is not immediate for outstanding tokens. Service tokens are
stateless — no row is persisted — so revoking the client fails all future
grants while tokens already issued age out at their natural expiry
(≤ AccessTokenExpiry, ~15 minutes).
Read next
- Registering an application — the calls that write these rows.
- The grants — what each
grant_typesvalue buys. - Redirect URIs — why the list is exact-match.