Register a client
Use this page to create the client that your app, service or agent uses to get tokens. The examples use two variables. POLICYARC_URL is your environment URL, and POLICYARC_ADMIN_KEY is an admin API key.
export POLICYARC_URL=https://your-environment.example
export POLICYARC_ADMIN_KEY="$(cat ~/.policyarc/admin-key)"
Choose how the client registers
| Method | Use it when | Page |
|---|---|---|
POST /register with no statement | An app or agent registers itself, and an admin grants it more later | This page |
POST /register with a software statement | A trusted issuer vouches for the client and states its grants | Register with a software statement |
POST /admin/api/clients | An operator creates the client and picks its client_id | This page |
| A client ID metadata document | An MCP client such as Claude Code or Cursor connects on its own | Connect clients |
| A SPIFFE JWT-SVID | A workload proves who it is with SPIFFE | SPIFFE workload identity |
Register at /register
This call registers a confidential client. Every registration needs redirect_uris, even for a client that never signs a user in. Each URI must be absolute, with no fragment. Use https, http on a loopback host, or a private-use scheme for a native app, such as com.example.app://.
resp=$(curl -sS -w '\n%{http_code}' -X POST "$POLICYARC_URL/register" \
-H 'Content-Type: application/json' \
-d '{"client_name": "reports-agent", "redirect_uris": ["https://reports-agent.example/cb"]}')
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 201 ] || { echo 'Expected HTTP 201' >&2; false; }
export CLIENT_ID=$(head -n1 <<<"$resp" | jq -er .client_id)
export CLIENT_SECRET=$(head -n1 <<<"$resp" | jq -er .client_secret)
The response is HTTP 201. Keep client_secret, because the server stores only a hash of it.
{
"client_id": "f81ec264-8e80-4606-9583-a21b3c728ca1",
"client_secret": "cDAvd1pwP7xQ0w...",
"client_id_issued_at": 1791905583,
"client_secret_expires_at": 0,
"redirect_uris": ["https://reports-agent.example/cb"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"client_name": "reports-agent"
}
A registration with no statement follows the public client policy row, pbac.operator.controls.public_client_policy. On a new environment that row has these dcr settings.
| Setting | New environment | Effect |
|---|---|---|
allowed_grant_types | authorization_code, refresh_token | The response lists only the requested grant types that are on this list. A request with none of them is refused. |
allowed_auth_methods | none, client_secret_basic | Any other token_endpoint_auth_method is refused. |
allowed_scopes and allowed_resource_indicators | openid, profile, email, mcp:tools, mcp:resources and read, on urn:as:userinfo and urn:mcp:server | None on what the client can get. The server keeps only the grant types from the registration decision. |
What the client can get comes from a binding on the dcr_clients segment in pbac.operator.segments, which matches every client registered without a statement. On a new environment that binding grants authorization_code and refresh_token, with openid, profile and email on urn:as:userinfo and mcp:tools and mcp:resources on urn:mcp:server. The urn:mcp:server entry carries "interfaces": ["mcp"], so it holds over MCP only. We set allowed_scopes to openid on urn:as:userinfo alone and registered a client on 0.65.0. Its response was unchanged, and once we added client_credentials to the binding it got mcp:resources on urn:mcp:server. A request for read on urn:mcp:server was refused with access_denied. To narrow what these clients get, change the binding. Controls and segments explains bindings.
Grant it client credentials
A client registered this way can sign users in, but it cannot get a token for itself. Give it an entitlement. This one allows client_credentials for the built-in introspection type.
curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
The policy engine picks up the change within a few seconds. Then ask for a token. The block waits first.
sleep 5
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=introspection)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 200 ] && head -n1 <<<"$resp" | jq -e '.scope == "introspection"' >/dev/null ||
{ echo 'Expected HTTP 200 with scope introspection' >&2; false; }
Without the entitlement, the same request returned HTTP 400 {"error":"access_denied","error_description":"Access denied"}. Control data explains the entitlement row.
Choose how the client authenticates
The client sends its credentials to /token, /introspect and /bc-authorize. Set the method with token_endpoint_auth_method at registration.
| Method | What the client sends | Notes |
|---|---|---|
client_secret_basic | Authorization: Basic with the client ID and secret | The default |
client_secret_post | client_id and client_secret as form fields | Refused at /register on a new environment. Add it to allowed_auth_methods first. |
private_key_jwt | A signed JWT in client_assertion, with client_assertion_type set to urn:ietf:params:oauth:client-assertion-type:jwt-bearer | Needs jwks_uri or jwks at registration. Refused at /register on a new environment, like client_secret_post. |
none | client_id only | A public client, such as a single page app. It must use PKCE. |
A private_key_jwt assertion needs these claims. sub is the client ID. aud holds the issuer, the token endpoint or the introspection endpoint. exp, iat and jti are required. By default iat may be at most 300 seconds old, set by pbac.oauth.timing.client-assertion-max-age-seconds. Each jti works once, so a replayed assertion is refused with invalid_client. The server checks the signature against the client's registered keys.
A wrong secret at /token returns HTTP 401 {"error":"invalid_client","error_description":"client_secret does not match the registered secret"}.
Create a client through the Admin API
An operator can create a client with the client_id it chooses. The create call stores no secret. Rotate one to get it.
resp=$(curl -sS -w '\n%{http_code}' -X POST "$POLICYARC_URL/admin/api/clients" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"clientId": "orders-api", "clientType": "confidential", "clientName": "Orders API", "redirectUris": ["https://orders-api.example/cb"]}')
echo "$resp"
case $(tail -n1 <<<"$resp") in
201) head -n1 <<<"$resp" | jq -e '.registrationType == "PRE_REGISTERED"' >/dev/null ;;
409) grep -qF 'already exists' <<<"$resp" ;;
*) echo 'Expected HTTP 201, or 409 when orders-api already exists' >&2; false ;;
esac
resp=$(curl -fsS -X POST "$POLICYARC_URL/admin/api/clients/orders-api/rotate-secret" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY")
echo "$resp"
jq -e '.client_id == "orders-api" and (.client_secret | length > 0)' <<<"$resp" >/dev/null ||
{ echo 'Expected a new client_secret for orders-api' >&2; false; }
The create call returns HTTP 201 with "registrationType": "PRE_REGISTERED". If orders-api already exists, it returns HTTP 409 and the block goes on to rotate the existing client's secret, so you can run the block again. The rotate call returns the secret once, with previous_secret_expires_in_seconds. A client created this way has no entitlement until you add one, as above.
If registration fails
Each refusal below is one we sent and saw.
| Request | Response |
|---|---|
No redirect_uris | HTTP 400 with a generic Bad Request body, not an OAuth error |
A redirect URI on plain http that is not loopback | invalid_redirect_uri |
Only grant_types: ["client_credentials"], with no statement | invalid_client_metadata, Registration denied: no_overlapping_grant_types |
token_endpoint_auth_method not in allowed_auth_methods | invalid_client_metadata, Registration denied: auth_method_not_allowed |
Every refusal is HTTP 400. The last three also write an audit row for /register.
Send feedback about this page