Authentication
Payerbox supports three OAuth 2.0 flows:
- SMART App Launch — member-authorized authorization code grant for third-party apps reading a single member's data (Patient Access).
- SMART Backend Services — system-to-system asymmetric JWT (
private_key_jwt) for Provider Access, Payer-to-Payer, PAS. - Client Credentials — symmetric client_id + client_secret grant for trusted internal services, sandboxes, and quick integrations.
SMART flows follow the HL7 SMART App Launch IG 2.2.0. The OAuth 2.0 RFC is RFC 6749.
Endpoint discovery
Fetch the SMART configuration document before configuring a client — it advertises the live token / authorization / JWKS URLs, supported scopes, signing algorithms, grant types, and capabilities.
GET <base>/.well-known/smart-configuration
Key fields Payerbox returns:
| Field | Value |
|---|---|
authorization_endpoint | <base>/auth/authorize |
token_endpoint | <base>/auth/token |
introspection_endpoint | <base>/auth/introspect |
jwks_uri | <base>/.well-known/jwks.json |
grant_types_supported | authorization_code, client_credentials, password, implicit, urn:ietf:params:oauth:grant-type:token-exchange |
token_endpoint_auth_methods_supported | client_secret_basic, client_secret_post, private_key_jwt |
token_endpoint_auth_signing_alg_values_supported | RS384, ES384 |
code_challenge_methods_supported | S256 |
capabilities | launch-standalone, launch-ehr, client-public, client-confidential-symmetric, client-confidential-asymmetric, permission-patient, permission-user, permission-v1, permission-v2, permission-offline, sso-openid-connect |
scopes_supported | openid, profile, email, launch, launch/patient, patient/*.cruds, system/*.cruds, user/*.cruds, fhirUser, offline_access, online_access |
Endpoints
| Endpoint | Used by |
|---|---|
<base>/auth/authorize | SMART App Launch — authorization redirect |
<base>/auth/token | Token issuance (all flows) |
<base>/auth/introspect | RFC 7662 token introspection |
<base>/auth/userinfo | OpenID Connect UserInfo |
<base>/.well-known/jwks.json | Server's public keys (used by clients to validate Payerbox-issued JWTs) |
<base>/.well-known/smart-configuration | SMART discovery document |
<base>/fhir/metadata | FHIR CapabilityStatement |
SMART App Launch (member-authorized)
Used by third-party apps in the FHIR App Portal. The member discovers an app in the FHIR App Gallery, clicks Launch, signs in, and grants the requested scopes.
Client registration
Each SMART app is registered as an Aidbox Client resource with type: smart-app and a code grant type:
PUT /Client/my-public-smart-app
Content-Type: application/json
{
"resourceType": "Client",
"id": "my-public-smart-app",
"type": "smart-app",
"active": true,
"grant_types": ["code"],
"auth": {
"authorization_code": {
"redirect_uri": "https://app.example.com/redirect",
"token_format": "jwt",
"access_token_expiration": 3600,
"refresh_token": true,
"secret_required": false,
"pkce": true
}
},
"smart": {"launch_uri": "https://app.example.com/launch"}
}
PUT /Client/my-confidential-smart-app
Content-Type: application/json
{
"resourceType": "Client",
"id": "my-confidential-smart-app",
"type": "smart-app",
"active": true,
"grant_types": ["code"],
"secret": "<client-secret>",
"auth": {
"authorization_code": {
"redirect_uri": "https://app.example.com/redirect",
"token_format": "jwt",
"access_token_expiration": 3600,
"refresh_token": true,
"secret_required": true,
"pkce": true
}
},
"smart": {"launch_uri": "https://app.example.com/launch"}
}
Public apps (single-page apps, native apps without a server-side component) must use PKCE; confidential apps may opt in to PKCE as defence-in-depth.
Authorization request
GET <base>/auth/authorize?<params>
| Parameter | Description |
|---|---|
response_type | Fixed value code. |
client_id | Client resource id. |
redirect_uri | Must match the registered Client.auth.authorization_code.redirect_uri. |
scope | Space-separated SMART scopes. Include launch/patient for standalone or launch for EHR launch, plus openid fhirUser for identity and offline_access to receive a refresh token. |
aud | FHIR base URL the app intends to call (typically <base>/fhir). |
state | Opaque value used to prevent CSRF; the server echoes it on the redirect back. |
code_challenge + code_challenge_method | Required for public apps (PKCE). code_challenge_method is always S256. |
launch | EHR launch only — the JWT identifier the EHR passed to the app. |
Standalone launch
The user picks the app from outside the EHR. The app goes straight to /auth/authorize with scope=launch/patient ..., then exchanges the returned code at /auth/token.
EHR launch
The EHR initiates a launch by opening the app at Client.smart.launch_uri with iss=<base>/fhir and launch=<launch-jwt>. The app then performs the same authorization code flow, passing the launch value back to /auth/authorize.
The launch JWT is signed by Aidbox and carries the launch context:
| Claim | Value |
|---|---|
client | SMART client id |
user | Aidbox user id |
ctx.patient | Patient id |
exp | Expiration (seconds since epoch) |
A helper RPC builds a complete launch URL:
POST /rpc
Content-Type: application/json
{
"method": "aidbox.smart/get-launch-uri",
"params": {
"client": "my-public-smart-app",
"user": "<user-id>",
"iss": "<base>/fhir",
"ctx": {"patient": "<patient-id>"}
}
}
Token request
After receiving the authorization code, exchange it for an access token:
POST /auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<code>
&redirect_uri=https://app.example.com/redirect
&client_id=my-public-smart-app
&code_verifier=<verifier>
Confidential clients omit client_id from the body and authenticate with Authorization: Basic <base64(client_id:client_secret)> (or send client_id + client_secret in the body).
{
"token_type": "Bearer",
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"id_token": "<jwt>",
"scope": "launch/patient openid fhirUser offline_access patient/*.read",
"patient": "<patient-id>",
"expires_in": 3600,
"need_patient_banner": true
}
Refresh token
If the app requested offline_access it can refresh the access token without user interaction:
POST /auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<refresh-token>
&client_id=my-public-smart-app
SMART Backend Services (system-to-system)
Used by Provider Access, Payer-to-Payer, and PAS. The client signs a JWT with its private key; Payerbox verifies it against the client's JWKS endpoint registered at onboarding. No client secret is shared.
Client registration
Each backend partner is registered as an Aidbox Client resource with grant_types: ["client_credentials"], auth.client_credentials.client_assertion_types: ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"], and a jwks_uri (or inline jwks) carrying the partner's public keys:
PUT /Client/partner-payer
Content-Type: application/json
{
"resourceType": "Client",
"id": "partner-payer",
"active": true,
"grant_types": ["client_credentials"],
"auth": {
"client_credentials": {
"client_assertion_types": ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"],
"token_format": "jwt",
"access_token_expiration": 300
}
},
"jwks_uri": "https://partner.example.com/.well-known/jwks.json",
"scope": ["system/*.read"],
"details": {
"identifier": [{"system": "http://hl7.org/fhir/sid/us-npi", "value": "<NPI>"}]
}
}
For operations that require the caller's NPI ($bulk-member-match, $provider-member-match), the NPI must be present on Client.details.identifier[system=http://hl7.org/fhir/sid/us-npi]. Aidbox rejects top-level Client.identifier, so the NPI lives under details.
Client assertion JWT
The client signs a short-lived JWT with its private key. Payerbox verifies the signature against the client's JWKS.
Header
| Field | Value |
|---|---|
alg | RS384 or ES384 |
kid | Key id matching one entry in the client's JWKS |
typ | JWT |
jku (optional) | TLS-protected URL of the client's JWK Set — must match Client.jwks_uri when present |
Claims
| Claim | Value |
|---|---|
iss | Client id |
sub | Client id (same as iss) |
aud | Token endpoint URL — <base>/auth/token |
exp | Expiration (≤ 5 minutes from now) |
jti | Unique nonce — prevents replay |
Token request
POST /auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=system/Patient.read+system/ExplanationOfBenefit.read
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed-JWT>
{
"token_type": "Bearer",
"access_token": "<jwt>",
"scope": "system/Patient.read system/ExplanationOfBenefit.read",
"expires_in": 300
}
Client JWKS
At onboarding, the partner registers either a jwks_uri (preferred — Payerbox refreshes the cached keys per the Cache-Control header) or an inline jwks array. Each JWK must include kty and kid; RSA keys also need n and e, EC keys need crv, x, and y. Key rotation is non-breaking — publish the new key, keep the old one until clients have rotated, then retire the old key.
Client Credentials
Plain OAuth 2.0 client_credentials grant with a pre-shared client_id + client_secret. Intended for trusted internal services, local stacks, and the quickstart — not for production payer-to-payer or provider integrations, which must use SMART Backend Services (asymmetric).
Client registration
PUT /Client/my-service
Content-Type: application/json
{
"resourceType": "Client",
"id": "my-service",
"active": true,
"grant_types": ["client_credentials"],
"secret": "<client-secret>"
}
An access policy must grant the client access to whatever resources it will read or write.
Token request
POST /auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=my-service
&client_secret=<client-secret>
POST /auth/token
Authorization: Basic <base64(my-service:<client-secret>)>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
{
"token_type": "Bearer",
"access_token": "<token>",
"need_patient_banner": true
}
Use the returned access_token as Authorization: Bearer <token> on any FHIR request.
Scopes
Payerbox supports both SMART v1 and v2 scope syntax (capabilities permission-v1 and permission-v2 are both advertised).
| Context | Use |
|---|---|
patient/ | Member-scoped (SMART App Launch). The access token's context.patient claim identifies which member. |
system/ | System-level (Backend Services / Client Credentials). No per-member filter; access is governed by access policies, attribution, and consent. |
user/ | User-scoped — used when a logged-in clinician launches an app. |
Permissions:
- v1 —
read,write,*(e.g.patient/*.read,system/Claim.write). - v2 — granular CRUDS letters:
c(create),r(read),u(update),d(delete),s(search). Wildcard*allowed (e.g.patient/*.cruds,system/Patient.rs).
Scopes with search parameters (v2)
Append a FHIR search query to a v2 read/search scope to narrow what the token can pull:
patient/Observation.rs?status=final
Aidbox auto-applies the filter to every search and read under that scope. Disallowed inside c / u / d permissions and disallowed with _include, _revinclude, _has, _assoc, _with.
Common scope sets
| Surface | Recommended scopes |
|---|---|
| Patient Access app (read-only) | openid fhirUser patient/*.read offline_access |
| Provider Access (Backend Services) | system/*.read |
| Payer-to-Payer (Backend Services) | system/*.read |
| PAS (Backend Services) | system/Claim.cu system/ClaimResponse.r |
Full inventory of what a given deployment exposes is in the SMART configuration document's scopes_supported.
Common errors
| HTTP | OAuth error | Cause |
|---|---|---|
| 400 | invalid_request | Missing or malformed parameter |
| 400 | invalid_grant | Authorization code expired, already used, or refresh token invalid |
| 400 | invalid_client | Unknown client_id, wrong secret, or client assertion signature did not verify against the registered JWKS |
| 400 | invalid_scope | Requested scope not allowed for this client |
| 401 | invalid_token | Access token expired, revoked, or signature invalid |
| 403 | insufficient_scope | Token lacks the required scope for the resource (returned as OperationOutcome from /fhir) |