Registering software
Dynamic Client Registration (DCR) lets software automatically register itself as an OAuth client at runtime by calling POST /register — no Admin API key required. The AS evaluates every registration request through OPA before issuing credentials, so policy governs which software is allowed to register, what grant types it may use, and whether an accompanying resource server record should be created.
When to use which
| Scenario | Method | Why |
|---|---|---|
| First-time setup, creating test clients | Admin API | No software statement needed; direct control |
| Automated onboarding of partner apps | DCR | Self-service; policy governs what's allowed |
| Production clients managed by your team | Admin API | Full control over credentials and config |
| Federated ecosystem (many issuers, many clients) | DCR | Scalable; trust issuers sign software statements |
| Resource servers that need auto-created RS records | DCR | Policy returns is_resource_server: true |
Rule of thumb: Use the Admin API when you control the client lifecycle. Use DCR when clients register themselves and policy decides what they get.
Prerequisites
- A running PBAC instance (see the base provisioning guide).
- A software statement JWT issued by a trusted authority (explained below). Registration without a software statement is possible only if
data.oauth.flow.register.require_software_statementisfalsein OPA policy data. - If
PBAC_SOFTWARE_STATEMENT_VERIFY=true: the signing key must be reachable at the JWKS URI declared in the JWT'sissdiscovery document.
Creating a software statement
A software statement is a signed JWT that asserts client metadata. The AS parses it before passing the claims to OPA; OPA uses the claims to decide whether and how to register the client.
Minimum claims
{
"iss": "https://trust.example.com",
"sub": "software-id-my-app",
"iat": 1700000000,
"grant_types": ["authorization_code", "refresh_token"],
"granted_resources": [
{
"type": "urn:example:Patient",
"scopes": ["read"],
"resource_indicator": "https://api.example.com/fhir"
}
]
}
| Claim | Required | Description |
|---|---|---|
iss | Yes | Issuer URI. Must appear in data.oauth.trust.trusted_issuers in OPA policy data. |
sub | Yes | Software identifier. Used as the lookup key in data.oauth.software_id. |
grant_types | Yes | Grant types the software may use. The policy denies registrations with an empty list. |
granted_resources | Conditionally | Resources this client will consume. Each entry: { type, scopes, resource_indicator? }. Required unless resources is non-empty. |
resources | Conditionally | Resources this client will host (resource server mode). Each entry: { type, scopes, resource_indicator }. Triggers RS record creation when non-empty. |
trust_level | No | "high" | "medium" | "low". A value of "low" triggers the step_up decision and blocks auto-registration. |
| (custom) | No | Any additional claim is available to OPA at input.request.software_statement.<claim> during registration and at input.client.software_statement.<claim> during token issuance. |
The types referenced in granted_resources and resources must exist in data.oauth.resource.types; OPA will deny the request otherwise.
Signature verification
By default (PBAC_SOFTWARE_STATEMENT_VERIFY=false) the AS decodes the JWT without verifying its signature — suitable for development. In production, set PBAC_SOFTWARE_STATEMENT_VERIFY=true. The AS will then fetch the issuer's JWKS URI and verify the RS256 (or equivalent) signature before processing the claims.
Registering a client
Basic registration
Confidential app registering with a software statement:
SOFTWARE_STATEMENT="<base64url-header>.<base64url-payload>.<signature>"
curl -X POST https://pbac.example.com/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://portal.example.com/callback"],
"client_name": "Patient Portal",
"client_uri": "https://portal.example.com",
"token_endpoint_auth_method": "client_secret_basic",
"software_statement": "'"$SOFTWARE_STATEMENT"'"
}'
Successful response (201 Created):
{
"client_id": "550e8400-e29b-41d4-a716-446655440000",
"client_secret": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop",
"client_id_issued_at": 1700000000,
"client_secret_expires_at": 0,
"redirect_uris": ["https://portal.example.com/callback"],
"client_name": "Patient Portal",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic"
}
client_secret_expires_at: 0 means the secret never expires (per RFC 7591). Store client_id and client_secret securely — the secret is not recoverable after this response.
Public client (SPA or mobile)
Set token_endpoint_auth_method to "none". No client_secret will be issued.
curl -X POST https://pbac.example.com/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://app.example.com/callback"],
"client_name": "My SPA",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"software_statement": "'"$SOFTWARE_STATEMENT"'"
}'
Private key JWT client
For private_key_jwt authentication, supply jwks_uri (HTTPS only) or an inline jwks. No client_secret is issued.
curl -X POST https://pbac.example.com/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://service.example.com/callback"],
"client_name": "Backend Service",
"token_endpoint_auth_method": "private_key_jwt",
"grant_types": ["client_credentials"],
"jwks_uri": "https://service.example.com/.well-known/jwks.json",
"software_statement": "'"$SOFTWARE_STATEMENT"'"
}'
jwks_uri must use the https scheme; the AS rejects non-HTTPS values with invalid_client_metadata.
DPoP-enabled client registration
To register a client that requests DPoP-bound access tokens, include dpop_bound_access_tokens: true in the registration body:
curl -X POST https://pbac.example.com/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://app.example.com/callback"],
"client_name": "Secure Agent",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"dpop_bound_access_tokens": true,
"software_statement": "'"$SOFTWARE_STATEMENT"'"
}'
After registration, the AS expects a DPoP proof header on every token request from this client. Requests that omit the header are rejected with invalid_dpop_proof.
Registration with resource server creation
When the software statement's resources array is non-empty, OPA sets is_resource_server: true in the registration output and the AS automatically creates a resource server record alongside the client. This means a single /register call provisions both the RS OAuth client and its resource server entry.
Software statement for a FHIR resource server:
{
"iss": "https://trust.example.com",
"sub": "software-id-fhir-rs",
"iat": 1700000000,
"grant_types": ["client_credentials", "authorization_code"],
"granted_resources": [
{ "type": "urn:as:introspect", "scopes": ["uma_protection"] },
{ "type": "urn:as:userinfo", "scopes": ["openid"] }
],
"resources": [
{
"type": "urn:example:Patient",
"scopes": ["read", "write"],
"resource_indicator": "https://api.example.com/fhir/*"
}
],
"trust_level": "high"
}
Registration request:
curl -X POST https://pbac.example.com/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://api.example.com/callback"],
"client_name": "FHIR API",
"client_uri": "https://api.example.com",
"token_endpoint_auth_method": "client_secret_basic",
"software_statement": "'"$RS_SOFTWARE_STATEMENT"'"
}'
After this call:
- A client record exists with
client_id/client_secret. - A resource server record exists with
resourceIndicator = "https://api.example.com/fhir/*"and the resource types (urn:example:Patient) attached.
Clients that subsequently request tokens for https://api.example.com/fhir/Patient/123 will be matched against this RS via the /* wildcard.
What happens after registration
-
Credentials issued. The AS generates a UUID
client_idand (for confidential non-private_key_jwtclients) a cryptographically random 32-characterclient_secret. The secret is BCrypt-hashed before being stored in the database. -
Software statement claims stored. The raw claims from the software statement are persisted on the
Cliententity. At token issuance and introspection time, OPA receives these claims atinput.client.software_statement, so policy decisions at those later stages reflect what was declared at registration time. -
Resource server created (conditional). If
is_resource_serveristruein the OPA output, the AS callsResourceServerAdminService.createOrUpdateFromDcr()for eachresource_indicatorfound inresources[].resource_indicator. Resource type URIs from theresourcesarray are attached to the RS record. -
Audit log entry written. Every
/registercall — allow or deny — is appended to the audit log with the full OPA input and policy decision. Query it viaGET /admin/api/audit-logs. -
Token requests. Use the
client_idandclient_secretin standard OAuth token requests. Forclient_credentials:curl -X POST https://pbac.example.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=550e8400-e29b-41d4-a716-446655440000" \
-d "client_secret=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir/Patient/123" \
-d "resource_types=urn:example:Patient"
Controlling registration with policy
The default register policy (oauth/register.rego) evaluates each request and returns allow, deny, or step_up. You can tune behaviour through policy data or custom extension policies.
Require a software statement
{ "oauth": { "flow": { "register": { "require_software_statement": true } } } }
Block a compromised software ID
{ "oauth": { "denylist": { "software_ids": ["compromised-software-abc"] } } }
Block a revoked issuer
{ "oauth": { "denylist": { "issuers": ["https://revoked-idp.example.com"] } } }
Block redirect URI domains
{ "oauth": { "denylist": { "domains": ["phishing.example.com", "evil.com"] } } }
Custom deny logic (extension policy)
Create or edit opa/policies/oauth/register_ext.rego:
package oauth.register.ext
import future.keywords.if
input_req := object.get(input, "input", input)
default deny = false
# Only allow registrations from internal networks
deny if {
not startswith(input_req.environment.ip, "10.")
not startswith(input_req.environment.ip, "192.168.")
}
reason := "custom:registration_not_from_internal_network" if deny
See the base provisioning guide for more policy extension examples.
Common errors
| HTTP status | Error code | Cause | Fix |
|---|---|---|---|
| 400 | invalid_software_statement | JWT is malformed or cannot be decoded | Verify the JWT has three base64url-encoded segments separated by . |
| 400 | unapproved_software_statement | sub or iss is in the denylist | Remove the entry from data.oauth.denylist or use a different software ID |
| 400 | registration_requires_approval | trust_level is "low", triggering step_up | Raise trust_level to "medium" or "high", or get admin approval |
| 400 | missing_software_statement | No software statement sent but one is required | Include a software_statement field, or set require_software_statement: false in policy data |
| 400 | untrusted_issuer | The JWT iss is not in data.oauth.trust.trusted_issuers | Add the issuer to the trusted list |
| 400 | missing_explicit_declarations:grant_types | grant_types claim is absent or empty in the software statement | Add a non-empty grant_types array to the software statement |
| 400 | missing_explicit_declarations:resource_declarations | Both granted_resources and resources are absent or empty | Add at least one entry to granted_resources or resources |
| 400 | invalid_resource_type | A type URI in granted_resources or resources is not registered | Create the resource type via POST /admin/api/resource-types first |
| 400 | invalid_client_metadata: private_key_jwt requires jwks_uri or jwks | private_key_jwt auth method selected but no key material provided | Add jwks_uri (HTTPS) or inline jwks to the request |
| 400 | invalid_client_metadata: jwks_uri must use HTTPS scheme | jwks_uri has an http:// scheme | Use an https:// URL for jwks_uri |
| 400 | invalid_redirect_uri | A redirect URI fails validation | Ensure all redirect URIs are absolute URIs with no wildcards |
| 400 | policy_denied | OPA returned deny with no specific reason | Check the OPA register policy and audit log for details |
Next steps
- Base provisioning — provision IdPs, resource types, and policy data that DCR clients depend on.
- Policy data configuration — configure
data.oauthtrust settings, denylists, and entitlement overrides. - Token exchange — use DCR-registered clients in token exchange flows.
- RFC 7591 compliance reference — full compliance status for the
/registerendpoint.