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 auth | SMART Backend Services | |
|---|---|---|
| Credential | Long-lived SSH key | Private key → signed JWT per token request |
| Public key distribution | Emailed once | JWKS URL (rotation = publish a new key) |
| Lifetime | Until removed | Assertion ≤ 5 min; access token short-lived |
| Authorization | Folder permissions | OAuth scopes + per-instance checks |
| Replay protection | n/a | jti uniqueness |
| Discovery | Out-of-band email | /.well-known/smart-configuration |
What ATR requires
From the ATR spec:
- Consumers need
system/*.read; producers should also supportsystem/*.writefor list creation and reconciliation. - The server publishes a SMART well-known configuration with
token_endpoint,scopes_supported,token_endpoint_auth_methods_supportedandtoken_endpoint_auth_signing_alg_values_supported. - Consumers register their JWKS by URL and obtain tokens per SMART Backend Services.
- For a specific Group, the server must check that the client may access that instance, not just the Group type.
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:
- Never fetch keys from the
jkuin the assertion without checking it against registration. Otherwise anyone can point you at their own JWKS. Here the server only uses the registered URL. - Replay cache must outlive the assertion. Keep
jtientries until theirexppasses; a Redis set with TTL works. - Clock skew. Allow a small leeway (PyJWT’s
leeway=) — a few seconds, not minutes. - Scope ≠ authorization.
system/Group.rssays what kind of data; the instance check says which list. ATR requires both.
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
- SMART App Launch — Backend Services
- SMART App Launch — scopes (v2 syntax)
- SMART App Launch — well-known configuration
- ATR formal specification — authorization requirements
- RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication
- RFC 7517 — JSON Web Key (JWK)
- PyJWT — usage and PyJWKClient
- OpenSSL ecparam
29 Oct 2026 · #fhir #security #oauth2 #python #rosterapiseries
GLB_