SMART Backend Services
SMART backend services authentication issues short-lived access tokens (5-minute TTL) using the client_credentials grant with a private_key_jwt client assertion. The client signs a JWT with its private key; the server verifies the signature against the client's JWKS endpoint.
This mode corresponds to the HL7 SMART Backend Services specification.
Prerequisites
- A registered API key in Practice Settings with a JWKS URI set.
- An RS384 (RSA 2048+ bit) key pair. The public key is served from your JWKS endpoint.
- A reachable JWKS endpoint (HTTPS, public, no auth required). Huli fetches it to verify the client assertion signature.
Key generation
Generate an RS384 key pair:
# Generate private key (2048-bit minimum; 4096-bit recommended)
openssl genrsa -out private.pem 4096
# Extract public key
openssl rsa -in private.pem -pubout -out public.pem
# Convert to JWK format (install node-jose-tools or similar)
npx node-jose-tools key-to-jwk --input private.pem --use sig --alg RS384
Serve the public JWK at your JWKS endpoint. The endpoint must return:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS384",
"kid": "huli-key-2026-06-01",
"n": "<base64url-encoded modulus>",
"e": "AQAB"
}
]
}
The kid (key ID) must match the kid in your client assertion header (see below).
Registering the JWKS URI
In Practice Settings → Integrations → API Keys, set the JWKS URI field to the URL of your JWKS endpoint (e.g., https://keys.example.com/.well-known/jwks.json).
Huli caches JWKS responses for 55 seconds and re-fetches at most once per 60 seconds per URI. Plan key rotations to overlap with the cache TTL.
Client assertion format
Build a JWT signed with your private key:
{
"alg": "RS384",
"kid": "huli-key-2026-06-01"
}
{
"iss": "<your client_id>",
"sub": "<your client_id>",
"aud": "https://api.huli.ai/fhir",
"iat": 1748808600,
"exp": 1748808900,
"jti": "unique-token-id-e8f2a1c9"
}
Rules:
issandsubmust both equal yourclient_id(the API key identifier visible in Practice Settings).audmust be exactlyhttps://api.huli.ai/fhir.expmust be within 5 minutes ofiat. Assertions with longer lifetimes are rejected.jtimust be unique per assertion. Huli records used JTI values to prevent replay attacks. Re-using a JTI within the assertion TTL returns401withHPB-00106.
Token exchange
/auth/tokenCLIENT_ASSERTION=$(python3 - <<'EOF'
import jwt, time, uuid
from cryptography.hazmat.primitives.serialization import load_pem_private_key
private_key = load_pem_private_key(open("private.pem", "rb").read(), password=None)
now = int(time.time())
payload = {
"iss": "your-client-id",
"sub": "your-client-id",
"aud": "https://api.huli.ai/fhir",
"iat": now,
"exp": now + 270,
"jti": str(uuid.uuid4()),
}
print(jwt.encode(payload, private_key, algorithm="RS384",
headers={"kid": "huli-key-2026-06-01"}))
EOF
)
curl -X POST https://api.huli.ai/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=${CLIENT_ASSERTION}" \
-d "scope=system/Patient.rs system/Appointment.rs"
Successful response:
{
"access_token": "eyJhbGciOiJSUzM4NCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "system/Patient.rs system/Appointment.rs"
}
expires_in is always 300 seconds (5 minutes). Schedule your client to request a new token before the current one expires. A common pattern is to refresh at 240 seconds (80% of TTL) to avoid clock skew issues.
Using the access token
curl https://api.huli.ai/fhir/R4/Patient?_count=1 \
-H "Authorization: Bearer eyJhbGciOiJSUzM4NCJ9..." \
-H "Accept: application/fhir+json"
Key rotation
To rotate keys without downtime:
- Generate a new key pair with a new
kid. - Add the new public key to your JWKS endpoint (serve both old and new).
- Begin signing new assertions with the new
kid. - After 60 seconds (one JWKS cache TTL), Huli will have fetched the updated JWKS.
- Remove the old key from your JWKS endpoint.
Security notes
- Keep the private key out of environment variables in production. Use a KMS or HSM.
- SSRF: Huli validates JWKS URIs against a deny-list (private IP ranges, metadata endpoints). Your JWKS URI must be a publicly routable HTTPS address.
- Circuit breaker: 5 consecutive authentication failures from one client trigger a 60-second block. This protects against credential stuffing. Failing fast and waiting is better than retrying immediately.