Most FHIR + MCP demos I see follow the same pattern. Someone points an MCP server at hapi.fhir.org/baseR4, asks Claude or another agent "show me this patient's latest HbA1c", and it works. It's a nice demo. But a public sandbox has no authentication at all, and no real FHIR server looks like that.
A real FHIR server sits behind an authorization server. It wants a token, it checks what that token is allowed to do, and it logs who did it. If you want an AI agent to read clinical data in production, the interesting question is not "can the model write a FHIR query". It's "how does the agent get a key, and what does that key open?"
This post is about that key. My main point is simple:
The guardrail around an AI agent is not the prompt. It's the token the FHIR server gives it.
The picture: two hops, no human
Here is the setup most teams end up with:
┌──────────────────────────────────────┐
│ User + AI host │ chat app, IDE assistant,
│ (any MCP client or agent harness) │ or an agent runtime
└───────────────────┬──────────────────┘
│ hop 1: MCP (STDIO or HTTP)
│ auth: MCP spec authorization (optional)
▼
┌──────────────────────────────────────┐
│ MCP server │ exposes FHIR as MCP tools
└───────────────────┬──────────────────┘
│ hop 2: FHIR REST + Bearer token
│ auth: OAuth2 client credentials <- this post
▼
┌──────────────────────────────────────┐
│ FHIR server │ HAPI, Medplum, an EHR,
│ + its authorization server │ a national platform
└──────────────────────────────────────┘
There are two separate trust relationships here, and it's easy to mix them up.
Hop 1 is the MCP client talking to the MCP server. The MCP specification has its own authorization chapter for this hop. In the current version (2026-07-28), authorization is optional, and when you use it over HTTP the MCP server acts as an OAuth 2.1 resource server: it publishes Protected Resource Metadata (RFC 9728), clients send a resource indicator (RFC 8707), and the server must only accept tokens that were issued for it. For the STDIO transport, the spec says implementations should not use this flow and should take credentials from the environment instead.
Hop 2 is the MCP server talking to the FHIR server. This is what this post is about. The MCP spec doesn't define this hop, but it does say one important thing about it: token passthrough is forbidden. The MCP server must not take the token it received on hop 1 and forward it to the FHIR server (security best practices). Hop 2 needs its own credential, issued by the FHIR server's authorization server, for the FHIR server.
On hop 2 there is usually no human in the loop. The agent calls a tool at 2 a.m. inside an automated workflow, and nobody is there to click "Allow" in a browser. That rules out the browser-based SMART App Launch flow (authorization code + PKCE) for this hop. What we need is machine-to-machine access, and in OAuth that means the client credentials grant.
Client credentials, plainly
Client credentials is the simplest OAuth flow: one request to get a token, then your normal FHIR call with it.
1. Ask for a token. The MCP server proves who it is to the token endpoint:
POST /realms/fhir/protocol/openid-connect/token HTTP/1.1
Host: auth.example.org
Content-Type: application/x-www-form-urlencoded
Authorization: Basic bGFuZ2NhcmUtbWNwOnMzY3IzdA==
grant_type=client_credentials&scope=system%2FObservation.rs
2. Get a token back:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "system/Observation.rs"
}
3. Call FHIR with it:
GET /fhir/Observation?patient=123&code=http://loinc.org|4548-4 HTTP/1.1
Host: fhir.example.org
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/fhir+json
That's it. No redirect, no user, no refresh token. When the token expires, the client just asks for a new one.
In Python with httpx, a minimal client with caching looks like this:
import time
import httpx
class TokenClient:
def __init__(self, token_url: str, client_id: str, client_secret: str, scope: str):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.scope = scope
self._token: str | None = None
self._expires_at = 0.0
def get(self) -> str:
# Reuse the cached token until 30 seconds before it expires.
if self._token and time.time() < self._expires_at - 30:
return self._token
resp = httpx.post(
self.token_url,
data={"grant_type": "client_credentials", "scope": self.scope},
auth=(self.client_id, self.client_secret), # HTTP Basic = client_secret_basic
)
resp.raise_for_status()
body = resp.json()
self._token = body["access_token"]
self._expires_at = time.time() + int(body.get("expires_in", 60))
return self._token
def invalidate(self) -> None:
self._token = None
The grant type is the easy part. The part that actually matters is the line with auth=: how the client proves its identity to the token endpoint.
Generic OAuth2 vs SMART Backend Services
This is where "OAuth2 client credentials" and "SMART Backend Services" stop being the same thing. Both use grant_type=client_credentials. They differ in how the client authenticates and in what the scopes mean.
Generic OAuth2: a shared secret
Most authorization servers (Keycloak, Auth0, Azure Entra ID, Medplum) support client authentication with a shared secret, in one of two ways:
-
client_secret_basic: the client ID and secret go in an HTTP BasicAuthorizationheader. This is what thehttpxexample above does. -
client_secret_post: the client ID and secret go in the form body asclient_idandclient_secret.
It works and it's easy. The problem is the secret itself. The authorization server stores it, the client stores it, and anyone who copies it can be that client. It ends up in environment variables, CI settings, Kubernetes secrets, and sometimes in a config file someone pasted into a chat window.
SMART Backend Services: a signed assertion
SMART Backend Services (SMART App Launch 2.2.0, STU 2.2) also uses grant_type=client_credentials, but the client authenticates with an asymmetric key. The spec says a backend service SHALL register using the client-confidential-asymmetric profile. Here is how it works:
- The client generates a key pair and registers its public key with the FHIR authorization server, either as a JWK Set URL (
jwks_uri) or as a JWK Set uploaded at registration time. - For every token request, the client builds a short JWT and signs it with its private key.
- The authorization server checks the signature with the registered public key.
The JWT has a fixed shape:
| Part | Value |
|---|---|
header alg
|
RS384 or ES384 (clients must support both) |
header kid
|
ID of the key in the client's JWK Set |
header typ
|
JWT |
iss, sub
|
the client ID |
aud |
the token endpoint URL |
exp |
no more than 5 minutes in the future |
jti |
a unique value per assertion, to prevent replay |
The token request then looks like this:
grant_type=client_credentials
&scope=system/Observation.rs
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzM4NCIsImtpZCI6...
Building the assertion in Python with PyJWT (install pyjwt[crypto] for RSA support):
import time
import uuid
import jwt # PyJWT
def client_assertion(client_id: str, token_url: str, private_key_pem: bytes, kid: str) -> str:
now = int(time.time())
claims = {
"iss": client_id,
"sub": client_id,
"aud": token_url,
"exp": now + 300, # max 5 minutes
"jti": str(uuid.uuid4()), # unique per request
}
return jwt.encode(claims, private_key_pem, algorithm="RS384",
headers={"kid": kid, "typ": "JWT"})
In generic OAuth terms this method is called private_key_jwt (RFC 7523). The private key never leaves the client, the server only stores something public, and each assertion is single-use and expires in minutes. A stolen assertion is almost worthless. Compare that with a stolen client secret, which works until someone notices and rotates it.
A detail people often miss: SMART App Launch 2.2.0 also defines a symmetric (shared secret) client authentication profile. But Backend Services specifically points to the asymmetric one. So if your client uses a secret, you are doing OAuth2 client credentials, and you may be talking to a SMART-capable server, but you are not doing SMART Backend Services.
Scopes: system/ and what they mean
The other difference is scopes. SMART v2 scopes look like context/ResourceType.permissions, where the permissions are a subset of cruds (create, read, update, delete, search). For backend services the context is system:
-
system/Observation.rsreads and searches Observations -
system/Patient.rreads Patients by ID, no search -
system/*.rsreads and searches everything -
system/Observation.rs?category=http://terminology.hl7.org/CodeSystem/observation-category|laboratorycovers lab Observations only (search-parameter constraints are new in SMART v2)
One important point from the scopes chapter: system/ scopes are relative to a policy the server has pre-configured for that client. The scope is a request; the server decides what the client actually gets, and it may grant less than you asked for. Always read the scope field in the token response.
A generic OAuth server doesn't know any of this. To Keycloak, system/Observation.rs is just a string. If you want it to mean something, the FHIR server (or a gateway in front of it) has to enforce it.
Why this matters more for AI agents
With a normal backend integration, the code is deterministic. You know which queries it will run. With an agent, the queries are decided by a model at runtime, based on a prompt that might include user input, document text, or the output of another tool. You cannot fully predict what it will try.
That changes where the guardrails have to live.
Least privilege outside the model. "Only read lab results for the current patient" in a system prompt is a request, not a control. A token that only carries system/Observation.rs?category=...laboratory is a control. If the model tries to read MedicationRequest, the FHIR server returns 403, no matter how clever the prompt injection was.
One client per agent, so audit trails mean something. Give each agent or MCP server deployment its own client ID. Then the FHIR server's audit log (often an AuditEvent resource) can tell you "the discharge-summary agent read these 40 Observations", and not just "the integration user did something".
Short-lived tokens. If a token leaks through logs, traces, or a tool result echoed back into the conversation, a 5-minute lifetime limits the damage. The SMART Backend Services spec says the authorization server "SHOULD issue access tokens with a short lifetime, on the order of five minutes".
No secrets anywhere the model can see. The token request happens inside the MCP server, not in the model context. The model only sees tool names and results. Credentials should come from the environment or a secret manager, never from the prompt, never from a tool argument, and ideally not from a plain config file. With private_key_jwt, the private key can even live in a KMS or HSM that signs for you, so the MCP server never holds the raw key.
A small working demo
The demo uses three containers: Keycloak as the authorization server, HAPI FHIR as the FHIR server, and a small Python gateway (Starlette + PyJWT) in front of HAPI that validates the token against Keycloak's JWKS endpoint and checks its scope. The gateway is about 80 lines: it extracts the resource type from the FHIR URL, looks up the required system/ scope, and returns 403 if the token doesn't carry it. In production your FHIR server does this itself. HAPI only does it with an authorization interceptor, so for a demo the gateway is the shortest path.
Recorded live against the stack below. The 36-second wait in case 3 is shortened in the GIF.
The full code is on GitHub at budityw23/fhir-mcp-oauth2-demo. With Docker and Python 3.12+, it runs in three commands after pip install -r requirements.txt:
docker compose up -d # Keycloak, HAPI FHIR, gateway
python seed.py # 1 synthetic patient, 3 Observations
python demo.py # the three cases below
Keycloak has one client, fhir-agent, with service accounts enabled and an access token lifetime of 60 seconds so we can watch it expire. Two optional client scopes are registered. One detail worth noting: Keycloak does not allow / in client scope names, so the scopes are named system-Observation.rs and system-Patient.rs (with a dash). The gateway normalizes the dash back to a slash when checking. A SMART-native server would use the canonical system/Observation.rs format directly.
The MCP tool, using the TokenClient from above and the official MCP Python SDK:
import os
import httpx
from mcp.server.mcpserver import MCPServer # mcp SDK v2: FastMCP was renamed to MCPServer
FHIR_BASE = os.environ["FHIR_BASE_URL"]
tokens = TokenClient(
token_url=os.environ["TOKEN_URL"],
client_id=os.environ["CLIENT_ID"],
client_secret=os.environ["CLIENT_SECRET"],
scope=os.environ.get("FHIR_SCOPE", "system-Observation.rs"),
)
mcp = MCPServer("fhir-observations")
@mcp.tool()
def search_observations(patient_id: str, code: str | None = None) -> dict:
"""Search Observations for a patient, optionally filtered by LOINC code."""
params = {"patient": patient_id}
if code:
params["code"] = code
for attempt in range(2):
resp = httpx.get(
f"{FHIR_BASE}/Observation",
params=params,
headers={"Authorization": f"Bearer {tokens.get()}",
"Accept": "application/fhir+json"},
)
if resp.status_code == 401 and attempt == 0:
tokens.invalidate() # token rejected early (revoked, clock skew): retry once
continue
break
if resp.status_code == 403:
return {"error": "forbidden",
"detail": "This agent's token does not allow this request."}
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
mcp.run() # STDIO: credentials come from the environment, as the MCP spec suggests
Notice how the tool returns a 403 to the model as a clear message instead of raising an error. The model can then tell the user "I'm not allowed to access that", which is better than retrying in a loop or making something up.
To make each case easy to watch, demo.py drives the same TokenClient and gateway directly, without an MCP client in between. The tool above makes exactly the same calls.
Case 1: valid token
$ python demo.py
=== Case 1: Valid token, Observation search ===
token: requested scope=system-Observation.rs, granted scope=system-Observation.rs, expires_in=60
GET /fhir/Observation?patient=example -> 200, Bundle with 3 entries
Case 2: wrong scope, 403
The same client asks for a Patient search, but its token only carries system-Observation.rs.
=== Case 2: Wrong scope, Patient search -> 403 ===
token: requested scope=system-Observation.rs, granted scope=system-Observation.rs, expires_in=60
GET /fhir/Patient?name=smith -> 403
Response: {'error': 'forbidden', 'detail': 'token lacks required scope for GET Patient'}
This is the whole point of the post in one line. The model asked, the token said no.
Case 3: token near expiry, refreshed early
Wait for the token to approach its 30-second cache margin and call the tool again. The TokenClient sees the token is about to expire and fetches a new one before calling FHIR.
=== Case 3: Expired token, automatic refresh ===
token: requested scope=system-Observation.rs, granted scope=system-Observation.rs, expires_in=60
GET /fhir/Observation?patient=example -> 200
token expires in 60s (cache margin 30s), waiting 36s...
23s remaining, calling get() again...
token: requested scope=system-Observation.rs, granted scope=system-Observation.rs, expires_in=60
GET /fhir/Observation?patient=example -> 200
What's still hard
I'd like to say "just use SMART Backend Services" and stop there, but real deployments are messier.
Many servers only support client secrets. Plenty of FHIR servers and gateways, including some large platforms, only offer client_secret_basic or client_secret_post for machine clients. Keycloak supports signed JWT client authentication, but it's not the default, and you have to configure it on purpose. So in practice you'll often support both and push toward private_key_jwt where you can.
System scopes are coarse. system/Observation.rs means "all Observations this client is allowed to see", which for a backend client is often the whole server. SMART v2 search-parameter constraints help (by category, for example), but server support varies. "Only this patient, only for this encounter, only for the next 10 minutes" is not something a plain system scope can express. For that you need extra policy at the FHIR server or a gateway, or a token exchange step that narrows the token for a specific task.
Key rotation and JWKS hosting. With asymmetric keys, you need to publish a JWK Set somewhere the authorization server can reach, rotate keys without downtime (publish the new key, start signing with it, remove the old one later), and keep the kid values unique. That's real operational work. Cloud KMS signing helps, but it's one more moving part.
Hop 1 and hop 2 need to be designed together. If a human user starts the session, you probably want hop 2 to reflect that user's permissions too, not just the agent's. Client credentials can't express "on behalf of this clinician". That's where token exchange (RFC 8693) and SMART's user/ scopes come in, and it's a topic for another post.
Where LangCare fits
I opened a pull request (PR #10, currently under review) that adds generic OAuth2 client credentials support to LangCare, an open-source FHIR MCP server written in Go. Before that, the generic provider only supported a static bearer token, basic auth, or no auth. The PR adds auth.type: oauth2_client_credentials, built on Go's golang.org/x/oauth2/clientcredentials package. It supports client_secret_basic (auth_style: header) and client_secret_post (auth_style: params), caches tokens in memory, refreshes them before they expire, and ships with a Keycloak + HAPI test stack and the repo's first unit tests. It does not do private_key_jwt yet. LangCare already signs RS384 assertions for its Epic and OpenEMR providers, so bringing that into the generic provider is the natural next step toward full SMART Backend Services.
The core of the change is small, which is a good sign. The library does the hard parts:
oauthConfig := clientcredentials.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
TokenURL: cfg.TokenURL,
Scopes: cfg.Scopes,
EndpointParams: endpointParams,
AuthStyle: authStyle,
}
httpClient = oauthConfig.Client(context.Background())
httpClient.Timeout = 30 * time.Second
Closing
When people talk about AI safety in healthcare, they usually talk about the model: hallucinations, prompt injection, the system prompt. Those matter. But for data access, the model is the wrong place to put the boundary. The model will try things. The FHIR server decides.
So when you build an MCP server for FHIR, spend less time on the perfect prompt and more on the boring parts: one client per agent, the smallest scopes that work, short token lifetimes, asymmetric keys when the server supports them, and a clear 403 that goes back to the model as a normal answer.
The key you hand the agent is the real guardrail. Make it a small key.
References
- SMART App Launch 2.2.0 (STU 2.2), Backend Services
- SMART App Launch 2.2.0, Client Authentication: Asymmetric
- SMART App Launch 2.2.0, Scopes and Launch Context
- SMART App Launch 2.2.0, Example Backend Services flow
- Model Context Protocol specification 2026-07-28, Authorization
- Model Context Protocol, Security best practices
- RFC 6749 §4.4 (client credentials), RFC 7523 (JWT client authentication), RFC 8707 (resource indicators), RFC 9728 (protected resource metadata)
- Keycloak, Client Authentication with Signed JWT

Top comments (0)