Skip to main content
Version: 1.0

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

ScenarioMethodWhy
First-time setup, creating test clientsAdmin APINo software statement needed; direct control
Automated onboarding of partner appsDCRSelf-service; policy governs what's allowed
Production clients managed by your teamAdmin APIFull control over credentials and config
Federated ecosystem (many issuers, many clients)DCRScalable; trust issuers sign software statements
Resource servers that need auto-created RS recordsDCRPolicy 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_statement is false in OPA policy data.
  • If PBAC_SOFTWARE_STATEMENT_VERIFY=true: the signing key must be reachable at the JWKS URI declared in the JWT's iss discovery 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"
}
]
}
ClaimRequiredDescription
issYesIssuer URI. Must appear in data.oauth.trust.trusted_issuers in OPA policy data.
subYesSoftware identifier. Used as the lookup key in data.oauth.software_id.
grant_typesYesGrant types the software may use. The policy denies registrations with an empty list.
granted_resourcesConditionallyResources this client will consume. Each entry: { type, scopes, resource_indicator? }. Required unless resources is non-empty.
resourcesConditionallyResources this client will host (resource server mode). Each entry: { type, scopes, resource_indicator }. Triggers RS record creation when non-empty.
trust_levelNo"high" | "medium" | "low". A value of "low" triggers the step_up decision and blocks auto-registration.
(custom)NoAny 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

  1. Credentials issued. The AS generates a UUID client_id and (for confidential non-private_key_jwt clients) a cryptographically random 32-character client_secret. The secret is BCrypt-hashed before being stored in the database.

  2. Software statement claims stored. The raw claims from the software statement are persisted on the Client entity. At token issuance and introspection time, OPA receives these claims at input.client.software_statement, so policy decisions at those later stages reflect what was declared at registration time.

  3. Resource server created (conditional). If is_resource_server is true in the OPA output, the AS calls ResourceServerAdminService.createOrUpdateFromDcr() for each resource_indicator found in resources[].resource_indicator. Resource type URIs from the resources array are attached to the RS record.

  4. Audit log entry written. Every /register call — allow or deny — is appended to the audit log with the full OPA input and policy decision. Query it via GET /admin/api/audit-logs.

  5. Token requests. Use the client_id and client_secret in standard OAuth token requests. For client_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 statusError codeCauseFix
400invalid_software_statementJWT is malformed or cannot be decodedVerify the JWT has three base64url-encoded segments separated by .
400unapproved_software_statementsub or iss is in the denylistRemove the entry from data.oauth.denylist or use a different software ID
400registration_requires_approvaltrust_level is "low", triggering step_upRaise trust_level to "medium" or "high", or get admin approval
400missing_software_statementNo software statement sent but one is requiredInclude a software_statement field, or set require_software_statement: false in policy data
400untrusted_issuerThe JWT iss is not in data.oauth.trust.trusted_issuersAdd the issuer to the trusted list
400missing_explicit_declarations:grant_typesgrant_types claim is absent or empty in the software statementAdd a non-empty grant_types array to the software statement
400missing_explicit_declarations:resource_declarationsBoth granted_resources and resources are absent or emptyAdd at least one entry to granted_resources or resources
400invalid_resource_typeA type URI in granted_resources or resources is not registeredCreate the resource type via POST /admin/api/resource-types first
400invalid_client_metadata: private_key_jwt requires jwks_uri or jwksprivate_key_jwt auth method selected but no key material providedAdd jwks_uri (HTTPS) or inline jwks to the request
400invalid_client_metadata: jwks_uri must use HTTPS schemejwks_uri has an http:// schemeUse an https:// URL for jwks_uri
400invalid_redirect_uriA redirect URI fails validationEnsure all redirect URIs are absolute URIs with no wildcards
400policy_deniedOPA returned deny with no specific reasonCheck the OPA register policy and audit log for details

Next steps