Skip to main content

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​

MethodUse it whenPage
POST /register with no statementAn app or agent registers itself, and an admin grants it more laterThis page
POST /register with a software statementA trusted issuer vouches for the client and states its grantsRegister with a software statement
POST /admin/api/clientsAn operator creates the client and picks its client_idThis page
A client ID metadata documentAn MCP client such as Claude Code or Cursor connects on its ownConnect clients
A SPIFFE JWT-SVIDA workload proves who it is with SPIFFESPIFFE 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.

SettingNew environmentEffect
allowed_grant_typesauthorization_code, refresh_tokenThe response lists only the requested grant types that are on this list. A request with none of them is refused.
allowed_auth_methodsnone, client_secret_basicAny other token_endpoint_auth_method is refused.
allowed_scopes and allowed_resource_indicatorsopenid, profile, email, mcp:tools, mcp:resources and read, on urn:as:userinfo and urn:mcp:serverNone 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.

MethodWhat the client sendsNotes
client_secret_basicAuthorization: Basic with the client ID and secretThe default
client_secret_postclient_id and client_secret as form fieldsRefused at /register on a new environment. Add it to allowed_auth_methods first.
private_key_jwtA signed JWT in client_assertion, with client_assertion_type set to urn:ietf:params:oauth:client-assertion-type:jwt-bearerNeeds jwks_uri or jwks at registration. Refused at /register on a new environment, like client_secret_post.
noneclient_id onlyA 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.

RequestResponse
No redirect_urisHTTP 400 with a generic Bad Request body, not an OAuth error
A redirect URI on plain http that is not loopbackinvalid_redirect_uri
Only grant_types: ["client_credentials"], with no statementinvalid_client_metadata, Registration denied: no_overlapping_grant_types
token_endpoint_auth_method not in allowed_auth_methodsinvalid_client_metadata, Registration denied: auth_method_not_allowed

Every refusal is HTTP 400. The last three also write an audit row for /register.