---
title: SMART Backend Services
description: M2M authentication via client_credentials + private_key_jwt signed with RS384 — key generation, JWKS endpoint setup, client assertion format, and token exchange.
nav: Auth / JWKS
order: 2
version: v1
source: handwritten
updated: 2026-06-01
---

# 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](https://hl7.org/fhir/uv/bulkdata/authorization/).

## 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:

```bash
# 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:

```json
{
  "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:

```json
{
  "alg": "RS384",
  "kid": "huli-key-2026-06-01"
}
```

```json
{
  "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

<Endpoint method="POST" path="/auth/token" />

```bash
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:

```json
{
  "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

```bash
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.
