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:

  • iss and sub must both equal your client_id (the API key identifier visible in Practice Settings).
  • aud must be exactly https://api.huli.ai/fhir.
  • exp must be within 5 minutes of iat. Assertions with longer lifetimes are rejected.
  • jti must be unique per assertion. Huli records used JTI values to prevent replay attacks. Re-using a JTI within the assertion TTL returns 401 with HPB-00106.

Token exchange

POST/auth/token
CLIENT_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:

  1. Generate a new key pair with a new kid.
  2. Add the new public key to your JWKS endpoint (serve both old and new).
  3. Begin signing new assertions with the new kid.
  4. After 60 seconds (one JWKS cache TTL), Huli will have fetched the updated JWKS.
  5. 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.