Client Assertion (JWT Bearer) | Medplum

On this page

Client Assertion is an alternative to the Client Credentials flow that authenticates the client using a signed JWT instead of a shared client secret. The client signs the JWT with a private key; Medplum verifies it against the public keys published at the client's JWKS URI.

This approach is specified in RFC 7523 — JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants and is a cornerstone of the SMART on FHIR Backend Services specification.

When to Use Client Assertion

Prefer client assertion over a client secret when:

Prerequisites

  1. A Medplum account. (If not, please register.)
  2. A ClientApplication — create one on the Project Admin page.
  3. An asymmetric key pair (RSA or EC). See Generating a Key Pair below.
  4. A publicly accessible JWKS endpoint that hosts your public key.

Generating a Key Pair

You can generate a key pair with any standard tool. The examples below use openssl.

EC (P-384, recommended):

# Generate private key

openssl ecparam -name secp384r1 -genkey -noout -out private.pem

# Derive public key

openssl ec -in private.pem -pubout -out public.pem

RSA (2048-bit minimum):

# Generate private key

openssl genrsa -out private.pem 2048

# Derive public key

openssl rsa -in private.pem -pubout -out public.pem

Keep the private key secret. Publish only the public key via a JWKS endpoint (see below).

Medplum supports the following signing algorithms: ES256, ES384, ES512, RS256, RS384, RS512.

Publishing Your Public Key (JWKS)

Medplum fetches your public key at runtime from the URL you configure on the ClientApplication. The endpoint must:

Example JWKS response:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-384",
      "use": "sig",
      "kid": "my-key-1",
      "x": "<base64url-encoded x>",
      "y": "<base64url-encoded y>"
    }
  ]
}

Many platforms (AWS, GCP, Auth0, etc.) can host a JWKS endpoint for you. If you are self-hosting, you can use openssl or a library like jose to convert your PEM public key to JWK format.

Configuring the ClientApplication

Set the jwksUri field on your ClientApplication to the URL of your JWKS endpoint. You can do this in the Medplum App UI or via the FHIR API:

{
  "resourceType": "ClientApplication",
  "name": "My Backend Service",
  "jwksUri": "https://example.com/.well-known/jwks.json"
}

Obtaining a Token

JWT Claims

The JWT you sign must include the following claims:

Claim Required Value
iss Yes Your ClientApplication ID (the UUID)
sub Yes Your ClientApplication ID (the UUID)
aud Yes The Medplum token endpoint URL (e.g. https://api.medplum.com/oauth2/token)
iat Yes Issued-at time (Unix epoch seconds)
exp Yes Expiration time (Unix epoch seconds); keep this short (≤ 5 minutes)
jti Recommended A unique identifier for the JWT to prevent replay attacks

Token Request

Once you have a signed JWT, exchange it for an access token with a client_credentials grant:

JWT="<your-signed-jwt>"

curl -X POST https://api.medplum.com/oauth2/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=$JWT"
import { MedplumClient } from '@medplum/core';
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';

const CLIENT_ID = 'your-client-application-id';
const TOKEN_URL = 'https://api.medplum.com/oauth2/token';

// Must match your private key type: ES384 for a P-384 EC key, RS384 for an RSA key, etc.
const ALG = 'ES384';

async function main(): Promise<void> {
  // Load your private key (keep this secret!)
  const privateKeyPem = readFileSync('private.pem', 'utf8');
  const privateKey = await importPKCS8(privateKeyPem, ALG);

// Build and sign the JWT
  const jwt = await new SignJWT({})
    .setProtectedHeader({ alg: ALG })
    .setIssuedAt()
    .setIssuer(CLIENT_ID)
    .setSubject(CLIENT_ID)
    .setAudience(TOKEN_URL)
    .setExpirationTime('5m')
    .sign(privateKey);

// Authenticate with Medplum
  const medplum = new MedplumClient();
  await medplum.startJwtAssertionLogin(jwt);

// Now make authenticated requests
  const patients = await medplum.searchResources('Patient');
  console.log(patients);
}

main().catch(console.error);
import time
import uuid
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import jwt  # pip install PyJWT cryptography

CLIENT_ID = 'your-client-application-id';
TOKEN_URL = 'https://api.medplum.com/oauth2/token';

# Load your private key
with open('private.pem', 'rb') as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())

now = int(time.time())

payload = {
    'iss': CLIENT_ID,
    'sub': CLIENT_ID,
    'aud': TOKEN_URL,
    'iat': now,
    'exp': now + 300,  # 5 minutes
    'jti': str(uuid.uuid4()),
}

# The signing algorithm must match the key type:
# - ES384 requires an EC private key (e.g. P-384)
# - RSA private keys should use RS256 or RS384 instead
signed_jwt = jwt.encode(payload, private_key, algorithm='ES384')

response = requests.post(TOKEN_URL, data={
    'grant_type': 'client_credentials',
    'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    'client_assertion': signed_jwt,
}, headers={'Content-Type': 'application/x-www-form-urlencoded'})

access_token = response.json()['access_token']

On success, the response is a standard OAuth2 token response:

{
  "token_type": "Bearer",
  "access_token": "<YOUR_AUTH_TOKEN>",
  "expires_in": 3600
}

The access_token can then be used in subsequent API requests as a Bearer token.

Error Reference

error_description Cause
Invalid client assertion The client_assertion field is empty or not a valid JWT.
Client not found The iss/sub in the JWT does not match any ClientApplication ID.
Client must have a JWK Set URL The matching ClientApplication has no jwksUri configured.
Invalid client assertion audience The aud claim does not match the Medplum token endpoint URL.
Invalid client assertion issuer The iss claim does not match the ClientApplication ID.
Invalid client assertion signature The JWT signature could not be verified against any key in the JWKS.
Unsupported client assertion type The client_assertion_type value is not urn:ietf:params:oauth:client-assertion-type:jwt-bearer.