OAuth2 Resource Server Authentication ¶
ACA-Py can be configured to act as an OAuth2 Resource Server, delegating all authentication and authorisation to an external Authorization Server (AS) such as Keycloak, Auth0, or any other OIDC-compliant provider. This replaces the built-in API key and the ACA-Py-issued multitenant JWT with access tokens that the AS issues directly to callers.
Table of Contents ¶
- Overview
- Request Flow
- Scopes
- Configuration
- Startup Parameters
- Environment Variables
- Token Validation Modes
- Multitenancy and the
wallet_idClaim - Request Claim Storage and Compatibility
- Annotating Routes with Scopes
- Authorization Server Setup Guidance
- Keycloak Example
- Keycloak Hostname and Issuer
- Demo: Docker Compose with Keycloak
- Scripts
- Migrating from API Key / ACA-Py JWT
- Limitations
Overview¶
In the traditional ACA-Py security model there are two mechanisms for protecting the Admin API:
| Mode | Parameter | Token issuer |
|---|---|---|
| API key | --admin-api-key |
n/a — static shared secret |
| Multitenant JWT | multitenant.jwt_secret |
ACA-Py itself |
Both modes are binary — a caller is either fully authorised or not. There is no concept of scopes or roles.
The OAuth2 Resource Server mode introduces a third option:
| Mode | Parameter | Token issuer |
|---|---|---|
| OAuth2 RS | --oauth-jwks-uri / --oauth-introspection-endpoint |
External AS |
When OAuth2 RS mode is active:
- ACA-Py never issues tokens — the
create_auth_tokenmultitenant endpoint is not used. - ACA-Py never stores or validates a shared secret for bearer tokens.
- The AS is the sole authority on who may call which endpoints.
- Individual routes declare the minimum scope required to access them.
Request Flow¶
sequenceDiagram
participant C as Client
participant AS as Authorization Server
participant RS as ACA-Py (Resource Server)
C->>AS: (1) Authenticate<br/>(OIDC / PKCE / client credentials / etc.)
AS-->>C: (2) Access token
C->>RS: (3) API request<br/>Authorization: Bearer [token]
RS->>AS: (4) Fetch JWKS (cached)
AS-->>RS: JWKS signing keys
Note over RS: (5) Validate signature,<br/>expiry, iss, aud
opt Opaque token introspection fallback
RS->>AS: (6) POST introspect token
AS-->>RS: { active, scope, sub, wallet_id, … }
end
Note over RS: (7) Check token scopes<br/>against route requirement
RS-->>C: (8) API response
JWT validation occurs on every request. Introspection is only used for opaque tokens or when JWT decoding falls back to introspection. JWKS keys are cached in memory by PyJWKClient so the round-trip to the AS only occurs when the key set changes.
Scopes¶
ACA-Py uses the scope claim from the access token. Scopes are space-separated strings following the convention acapy:<resource>[:<action>].
| Scope | Intended use |
|---|---|
acapy:admin |
Full administrative access — wallet management, server config, ledger operations |
acapy:tenant |
Tenant-level access — credentials, connections, presentations for a specific sub-wallet |
acapy:tenant:read |
Read-only tenant access |
acapy:wallet:create |
Permission to create new sub-wallets only |
Scope-to-decorator mapping¶
Route handlers are annotated with one of two authentication decorators. In OAuth mode each decorator enforces a minimum scope:
| Decorator | OAuth scope required | Legacy auth |
|---|---|---|
@admin_authentication |
acapy:admin |
x-api-key or insecure mode |
@tenant_authentication |
acapy:tenant or acapy:admin |
Bearer JWT or x-api-key |
A request passes scope enforcement if it holds at least one of the required scopes. acapy:admin implies full access and satisfies both decorators. All /multitenancy/* routes use @admin_authentication and therefore require acapy:admin.
Configuration¶
Startup Parameters¶
| Parameter | Description |
|---|---|
--oauth-enabled |
Enable OAuth2 RS mode explicitly. Implied by either of the two parameters below. |
--oauth-jwks-uri <url> |
JWKS endpoint of the AS. ACA-Py fetches and caches signing keys from here to validate JWT access tokens locally. |
--oauth-issuer <issuer> |
Expected value of the iss claim. Tokens with a different issuer are rejected. |
--oauth-audience <audience> |
Expected value of the aud claim. Required whenever --oauth-jwks-uri is set — ACA-Py refuses to start without it, so JWT access tokens are always bound to this resource server. |
--oauth-introspection-endpoint <url> |
RFC 7662 introspection endpoint. Used as a fallback for opaque tokens, or as the sole validation method when --oauth-jwks-uri is not set. |
--oauth-introspection-client-id <id> |
Client ID for HTTP Basic Auth on the introspection endpoint. Required when the introspection endpoint is configured. |
--oauth-introspection-client-secret <secret> |
Client secret for HTTP Basic Auth on the introspection endpoint. |
--oauth-http-timeout <seconds> |
Timeout for HTTP calls to the AS (JWKS key fetches and token introspection). Default 10. |
When any of --oauth-jwks-uri or --oauth-introspection-endpoint is provided, neither --admin-api-key nor --admin-insecure-mode is required.
Configuration is validated at startup and ACA-Py refuses to start when:
- OAuth mode is enabled (via any of the three flags) but neither
--oauth-jwks-urinor--oauth-introspection-endpointis set — there would be no way to validate tokens. --oauth-introspection-endpointis set without--oauth-introspection-client-id.--oauth-jwks-uriis set without--oauth-audience— without an expected audience, any signature-valid token from the JWKS would be accepted regardless of its intended recipient (token reuse / confused-deputy on a shared Authorization Server).
Example startup using JWKS validation:
aca-py start \
--admin 0.0.0.0 8031 \
--oauth-jwks-uri https://auth.example.com/realms/acapy/protocol/openid-connect/certs \
--oauth-issuer https://auth.example.com/realms/acapy \
--oauth-audience acapy-resource-server \
--multitenant \
--multitenant-admin \
...
Example startup using introspection only (for opaque tokens):
aca-py start \
--admin 0.0.0.0 8031 \
--oauth-introspection-endpoint https://auth.example.com/oauth/introspect \
--oauth-introspection-client-id acapy-rs \
--oauth-introspection-client-secret <secret> \
...
Environment Variables¶
Each parameter has a corresponding environment variable:
| Parameter | Environment variable |
|---|---|
--oauth-jwks-uri |
ACAPY_OAUTH_JWKS_URI |
--oauth-issuer |
ACAPY_OAUTH_ISSUER |
--oauth-audience |
ACAPY_OAUTH_AUDIENCE |
--oauth-introspection-endpoint |
ACAPY_OAUTH_INTROSPECTION_ENDPOINT |
--oauth-introspection-client-id |
ACAPY_OAUTH_INTROSPECTION_CLIENT_ID |
--oauth-introspection-client-secret |
ACAPY_OAUTH_INTROSPECTION_CLIENT_SECRET |
--oauth-http-timeout |
ACAPY_OAUTH_HTTP_TIMEOUT |
Token Validation Modes¶
JWT via JWKS (recommended)
ACA-Py uses PyJWKClient (bundled with pyjwt >= 2.x) to fetch signing keys from the JWKS endpoint and validate tokens locally. Signature, expiry, issuer, and audience are all checked. Supported algorithms: RS256/384/512, ES256/384/512, PS256/384/512.
JWKS key fetches happen on a cache miss (first request, or key rotation) and are subject to --oauth-http-timeout. The fetch runs in a worker thread so a slow AS does not block other requests.
Introspection fallback
If JWKS validation fails with a decode error (e.g. the token is opaque, not a JWT) and --oauth-introspection-endpoint is configured, ACA-Py falls back to RFC 7662 introspection. The introspection response must include "active": true; the scope field is used for scope enforcement.
Introspection calls use a shared HTTP session with a timeout of --oauth-http-timeout seconds (default 10). If the AS cannot be reached (network error or timeout), ACA-Py responds with 503 Service Unavailable rather than 401, since the failure says nothing about the token's validity.
Combined mode
Providing both --oauth-jwks-uri and --oauth-introspection-endpoint gives you JWT-first validation with opaque token fallback. This covers deployments where the AS issues different token types to different clients.
Multitenancy and the wallet_id Claim¶
In multitenant deployments, ACA-Py needs to know which sub-wallet a request should be routed to. With the built-in JWT this was encoded as wallet_id in the token payload. With external OAuth tokens, the AS must include a custom claim named wallet_id containing the ACA-Py sub-wallet UUID.
When ACA-Py receives a request:
- The access token is validated (JWKS or introspection).
- The
wallet_idclaim is read from the token. - The corresponding
WalletRecordis loaded from storage. - The request is executed in that wallet's profile context.
If wallet_id is absent from the token, the request is rejected with 401 Unauthorized unless the token carries the acapy:admin scope. Admin-scoped tokens without a wallet_id run against the base wallet. This prevents a misconfigured AS (e.g. a tenant client missing its wallet_id mapper) from silently granting tenant tokens access to base-wallet data.
Provisioning flow¶
- An admin caller (holding
acapy:adminscope) creates a sub-wallet viaPOST /multitenancy/wallet. Awallet_keymust be provided even whenkey_management_modeismanaged— ACA-Py stores and manages the key but Askar requires one at creation time. - ACA-Py returns the new
wallet_idin the response. - The operator configures the AS to embed that
wallet_idas a custom claim in tokens issued to the corresponding user or client.
In Keycloak this is done with a Protocol Mapper of type Hardcoded Claim:
- Token Claim Name: wallet_id
- Claim Value: <the ACA-Py wallet UUID>
- Add to access token: enabled
Request Claim Storage and Compatibility¶
OAuth token-derived values (scope, sub, wallet_id) are attached to the
request in AdminRequestContext.metadata.
In addition, ACA-Py mirrors these values into request-scoped settings for
compatibility with integrations that already read from context.settings /
context.profile.settings.
Request-scoped settings keys:
| Claim | Request-scoped setting key |
|---|---|
scope |
auth.scopes |
sub |
auth.subject |
wallet_id |
auth.wallet_id |
Why both metadata and settings?¶
metadatais the canonical location for per-request auth claims.- Request-scoped settings provide a low-friction compatibility path for code and plugins that already rely on settings access patterns.
- This avoids storing OAuth claims in global/shared settings while still reducing break risk for existing extensions.
Recommended access pattern for core and plugins¶
Use helper functions from acapy_agent.admin.auth_context instead of reading
context.metadata or settings directly. The helpers normalize values and
handle both storage paths safely.
from acapy_agent.admin.auth_context import (
get_auth_scopes,
get_auth_subject,
get_auth_wallet_id,
has_auth_wallet_id,
)
context = request["context"]
scopes = get_auth_scopes(context)
subject = get_auth_subject(context)
wallet_id = get_auth_wallet_id(context)
is_subwallet = has_auth_wallet_id(context)
Annotating Routes with Scopes¶
Use the require_scope decorator from acapy_agent.admin.decorators.auth. It must be stacked inside tenant_authentication (or admin_authentication) so that authentication is checked before scope enforcement.
from acapy_agent.admin.decorators.auth import require_scope, tenant_authentication
@docs(tags=["credential"], summary="Issue a credential")
@tenant_authentication
@require_scope("acapy:tenant", "acapy:admin")
async def credential_issue(request: web.Request) -> web.Response:
...
A request passes if its token contains any one of the listed scopes. To require a scope that only admins hold, list only "acapy:admin".
For route logic, prefer the auth-context helpers:
from acapy_agent.admin.auth_context import (
get_auth_scopes,
get_auth_subject,
get_auth_wallet_id,
)
context = request["context"]
scopes = get_auth_scopes(context)
subject = get_auth_subject(context)
wallet_id = get_auth_wallet_id(context)
Authorization Server Setup Guidance¶
Keycloak Example¶
-
Create a Realm (e.g.
acapy). -
Create a Client representing ACA-Py as the resource server:
- Client ID:
acapy-resource-server - Access Type:
bearer-only -
This client does not issue tokens — it is used only as the audience target.
-
Create Client Scopes for each ACA-Py scope:
acapy:adminacapy:tenantacapy:tenant:readacapy:wallet:create-
Set Include in Token Scope to enabled on each.
-
Create Clients for callers:
-
Admin / controller (server-to-server): confidential client,
client_credentialsgrant, assignacapy:adminas default scope. Add an Audience protocol mapper pointing toacapy-resource-server. - Tenant service account (server-to-server): confidential client,
client_credentialsgrant, assignacapy:tenantas default scope. Add an Audience mapper and a Hardcoded Claim mapper forwallet_id. -
End-user client (browser): public client, authorization code + PKCE, assign
acapy:tenantas default scope. Add Audience andwallet_idmappers as above. -
Configure ACA-Py:
Keycloak Hostname and Issuer¶
The iss claim in a Keycloak-issued token reflects the URL from which the token was requested. In containerised deployments, clients outside the container network hit Keycloak on a host-accessible URL (e.g. http://localhost:8080) while ACA-Py reaches Keycloak on an internal Docker network URL (e.g. http://keycloak:8080). These produce different iss values, causing --oauth-issuer validation to fail.
The recommended fix is to set KC_HOSTNAME in Keycloak so that the iss claim always uses a single canonical URL regardless of which network interface handled the request:
# docker-compose.yml — Keycloak service
environment:
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: "8080"
KC_HOSTNAME_STRICT: "false"
Then set --oauth-issuer to match that canonical URL:
ACA-Py's --oauth-jwks-uri can still use the internal Docker network hostname for the JWKS fetch — issuer validation is a string comparison against the iss claim and requires no network call.
Demo: Docker Compose with Keycloak¶
A self-contained demo is provided in demo/demo-authserver/. It starts three services:
| Service | Image | Purpose |
|---|---|---|
keycloak |
quay.io/keycloak/keycloak:24 |
Authorization Server, pre-loaded with the acapy realm |
wallet-db |
postgres:16 |
ACA-Py wallet storage |
acapy |
Built from repo | ACA-Py configured as an OAuth2 Resource Server |
Quick start:
cd demo/demo-authserver
podman compose up --build # or docker compose up --build
# In a second terminal, once all services are healthy:
./scripts/setup-tenant.sh
Scripts¶
All scripts read common settings from a .env file if present and default sensibly otherwise.
| Script | Description |
|---|---|
setup-tenant.sh |
Creates an ACA-Py sub-wallet using an admin token, then updates the Keycloak wallet-id claim on the acapy-tenant-demo client. Must be run before the tenant scripts. |
get-admin-token.sh |
Obtains an admin token via client_credentials grant for acapy-controller and prints the decoded claims and raw token. |
get-tenant-token.sh |
Obtains a tenant token via client_credentials grant for a confidential tenant client (defaults to acapy-tenant-demo). Prints decoded claims including wallet_id. |
get-user-token.sh |
Creates a demo user in Keycloak (if needed) and performs a full authorization code + PKCE flow. Prints a Keycloak login URL to open in the browser; a local callback server on port 9999 receives the code and exchanges it for a token. |
Example usage after setup-tenant.sh:
# Server-to-server admin token
./scripts/get-admin-token.sh
# Server-to-server tenant token (confidential client)
./scripts/get-tenant-token.sh
# Browser-based user token (public client, PKCE)
./scripts/get-user-token.sh
# Call ACA-Py with a token
TOKEN=$(./scripts/get-admin-token.sh | grep -A1 "Access token:" | tail -1)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8031/multitenancy/wallets | jq .
Migrating from API Key / ACA-Py JWT¶
| Before | After |
|---|---|
--admin-api-key <key> |
Remove; add --oauth-jwks-uri (and/or introspection params) |
--admin-insecure-mode |
Remove; configure OAuth |
--multitenant-jwt-secret <secret> |
Still required by the multitenant subsystem but unused for token validation in OAuth mode |
POST /multitenancy/wallet/{id}/token |
Clients obtain tokens from the AS directly |
X-API-Key: <key> request header |
Authorization: Bearer <token> request header |
@admin_authentication on admin routes |
Unchanged decorator; now enforces acapy:admin scope in OAuth mode |
@tenant_authentication on tenant routes |
Unchanged decorator; now enforces acapy:tenant or acapy:admin scope in OAuth mode |
Limitations¶
- Unmanaged wallets are not supported in OAuth mode. The ACA-Py-issued JWT could carry a
wallet_keyfor wallets whose keys are not stored by ACA-Py. An OAuth access token from an external AS must not contain cryptographic wallet keys; use managed wallets (key_management_mode: managed) with OAuth. - WebSocket authentication uses the same bearer token presented in the initial HTTP upgrade request. In-message
x-api-keyre-authentication is not available in OAuth mode. The admin event stream (GET /wson the admin port) is scope- and wallet-aware: anacapy:admintoken receives events for all wallets, while anacapy:tenant/acapy:tenant:readtoken receives only events for thewallet_idin its token (and, in multitenant mode, a token without awallet_idis not permitted to receive events). This mirrors the tenant isolation enforced on the HTTP routes. Note this is the admin notification websocket, distinct from any DIDComm WebSocket inbound transport. - JWKS key rotation is handled automatically by
PyJWKClient's built-in cache, which re-fetches the key set when a token references an unknown key ID (kid). No ACA-Py restart is required.