Skip to content
>GLB_
Go back

From SSH keys to SMART Backend Services: auth for system-to-system FHIR

Part 5 of 7 — Roster API Series: migrating member-file ingestion from CSV over SFTP to a FHIR API.

In the SFTP world, auth is a key pair: you run ssh-keygen, email the public key to the vendor, and that key works until someone remembers to rotate it — which is never. No scopes, no expiry, no per-request decision.

Interestingly, the replacement keeps the good part (asymmetric keys, the private key never leaves you) and fixes the rest. That’s SMART Backend Services, the auth profile ATR mandates: a system signs a short-lived JWT with its private key, trades it for a short-lived access token with explicit scopes, and uses that token against the FHIR API.

SFTP keys vs SMART Backend Services

SFTP key authSMART Backend Services
CredentialLong-lived SSH keyPrivate key → signed JWT per token request
Public key distributionEmailed onceJWKS URL (rotation = publish a new key)
LifetimeUntil removedAssertion ≤ 5 min; access token short-lived
AuthorizationFolder permissionsOAuth scopes + per-instance checks
Replay protectionn/ajti uniqueness
DiscoveryOut-of-band email/.well-known/smart-configuration

What ATR requires

From the ATR spec:

system/*.read is SMART v1 syntax. In SMART v2 the equivalent is system/*.rs (read + search). I’d narrow it anyway: system/Group.rs system/Patient.rs system/Coverage.rs is enough for a roster consumer.

Step 1 — keys and JWKS (client)

$ openssl ecparam -name secp384r1 -genkey -noout -out client-key.pem
$ openssl ec -in client-key.pem -pubout -out client-pub.pem

ES384 or RS384 are the algorithms SMART requires servers to support. Publish the public key as a JWKS:

# make_jwks.py
import json

from jwt.algorithms import ECAlgorithm
from cryptography.hazmat.primitives import serialization

pub = serialization.load_pem_public_key(open("client-pub.pem", "rb").read())
jwk = json.loads(ECAlgorithm.to_jwk(pub))
jwk.update({"kid": "roster-client-2026-10", "alg": "ES384", "use": "sig"})
json.dump({"keys": [jwk]}, open("jwks.json", "w"), indent=2)

Host jwks.json at a stable HTTPS URL you control and register that URL (plus your client_id) with the server. Rotation is now: add the new key to the JWKS, start signing with its kid, remove the old key later. No cross-company ticket.

Step 2 — get a token (client)

# smart_client.py
import time
import uuid

import httpx
import jwt

FHIR_BASE = "https://roster.example.org/fhir"
CLIENT_ID = "roster-consumer-01"
PRIVATE_KEY = open("client-key.pem").read()


def discover() -> dict:
    return httpx.get(f"{FHIR_BASE}/.well-known/smart-configuration").json()


def get_token(scope="system/Group.rs system/Patient.rs system/Coverage.rs") -> str:
    token_url = discover()["token_endpoint"]
    now = int(time.time())
    assertion = jwt.encode(
        {
            "iss": CLIENT_ID,
            "sub": CLIENT_ID,
            "aud": token_url,
            "exp": now + 300,          # max 5 minutes
            "jti": uuid.uuid4().hex,   # unique per request
        },
        PRIVATE_KEY,
        algorithm="ES384",
        headers={"kid": "roster-client-2026-10",
                 "jku": "https://consumer.example.org/.well-known/jwks.json"},
    )
    r = httpx.post(token_url, data={
        "grant_type": "client_credentials",
        "scope": scope,
        "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        "client_assertion": assertion,
    })
    r.raise_for_status()
    return r.json()["access_token"]

That token goes into the Authorization: Bearer header used by the export client from Part 4.

Step 3 — issue and check tokens (server)

The token endpoint verifies the assertion against the client’s registered JWKS, then issues a short-lived token. I use HS256 for the access token here for brevity; in production, issue signed tokens your resource server verifies, or opaque tokens with introspection.

# auth.py
import time
import uuid

import jwt
from fastapi import Depends, FastAPI, Form, Header, HTTPException

app = FastAPI()
ISSUER = "https://roster.example.org"
TOKEN_URL = f"{ISSUER}/auth/token"
ACCESS_TOKEN_KEY = "change-me"  # demo only

CLIENTS = {  # registration: client_id -> JWKS URL + allowed scopes + allowed Groups
    "roster-consumer-01": {
        "jwks_url": "https://consumer.example.org/.well-known/jwks.json",
        "scopes": {"system/Group.rs", "system/Patient.rs", "system/Coverage.rs"},
        "groups": {"roster-2026"},
    }
}
SEEN_JTI: dict[str, int] = {}


@app.get("/fhir/.well-known/smart-configuration")
def smart_config():
    return {
        "token_endpoint": TOKEN_URL,
        "grant_types_supported": ["client_credentials"],
        "token_endpoint_auth_methods_supported": ["private_key_jwt"],
        "token_endpoint_auth_signing_alg_values_supported": ["ES384", "RS384"],
        "scopes_supported": ["system/Group.rs", "system/Patient.rs",
                             "system/Coverage.rs", "system/Group.cud"],
        "capabilities": ["client-confidential-asymmetric"],
    }


@app.post("/auth/token")
def token(grant_type: str = Form(...), scope: str = Form(...),
          client_assertion_type: str = Form(...), client_assertion: str = Form(...)):
    if grant_type != "client_credentials":
        raise HTTPException(400, "unsupported_grant_type")

    client_id = jwt.decode(client_assertion, options={"verify_signature": False})["iss"]
    client = CLIENTS.get(client_id)
    if not client:
        raise HTTPException(401, "invalid_client")

    # Verify signature against the *registered* JWKS URL — never trust jku blindly.
    signing_key = jwt.PyJWKClient(client["jwks_url"]).get_signing_key_from_jwt(client_assertion)
    try:
        claims = jwt.decode(client_assertion, signing_key.key,
                            algorithms=["ES384", "RS384"], audience=TOKEN_URL,
                            options={"require": ["iss", "sub", "aud", "exp", "jti"]})
    except jwt.PyJWTError as e:
        raise HTTPException(401, f"invalid_client: {e}")

    if claims["sub"] != client_id or claims["exp"] - time.time() > 300:
        raise HTTPException(401, "invalid_client")
    if claims["jti"] in SEEN_JTI:
        raise HTTPException(401, "invalid_client: replayed jti")
    SEEN_JTI[claims["jti"]] = claims["exp"]

    granted = set(scope.split()) & client["scopes"]
    if not granted:
        raise HTTPException(400, "invalid_scope")

    access = jwt.encode({"iss": ISSUER, "sub": client_id, "scope": " ".join(sorted(granted)),
                         "exp": int(time.time()) + 300, "jti": uuid.uuid4().hex},
                        ACCESS_TOKEN_KEY, algorithm="HS256")
    return {"access_token": access, "token_type": "bearer",
            "expires_in": 300, "scope": " ".join(sorted(granted))}


def require(scope: str):
    def dep(authorization: str = Header(...)) -> dict:
        try:
            claims = jwt.decode(authorization.removeprefix("Bearer "),
                                ACCESS_TOKEN_KEY, algorithms=["HS256"])
        except jwt.PyJWTError:
            raise HTTPException(401)
        if scope not in claims["scope"].split():
            raise HTTPException(403, f"missing scope {scope}")
        return claims
    return dep


@app.get("/fhir/Group/{gid}")
def read_group(gid: str, claims: dict = Depends(require("system/Group.rs"))):
    # Instance-level check required by ATR: scope alone is not enough.
    if gid not in CLIENTS[claims["sub"]]["groups"]:
        raise HTTPException(403, "client not authorized for this Group")
    ...

Things that bite in real implementations:

What changed vs SFTP

Every request now carries a token that expires in minutes, is bound to a client identity, lists exactly what it can do, and gets checked against the specific Group. Key rotation is a JWKS publish. The private key still never leaves the client — the one thing SSH auth got right is preserved.

Next: Part 6 — Change notifications, so consumers stop polling for a new list on a cron.

References


29 Oct 2026 · #fhir #security #oauth2 #python #rosterapiseries

GLB_


Share this post:

Related Posts


Previous Post
Roster change notifications with FHIR Subscriptions (R4 backport)
Next Post
Bulk export and async jobs: serving large rosters with $davinci-data-export