I wouldn't put system/*.rs behind an LLM.
Connect an AI agent to an EHR with SMART Backend Services and system/ scopes, and you've built a service that can read every patient the client policy allows. No user is attached to any of those reads. That's the wrong default.
My rule is simple. Backend Services is for narrow, unattended jobs. When a clinician is driving, the agent runs on that clinician's SMART App Launch token. Either way, the agent gets recorded as its own actor, so every chart access traces back to a model run.
Every EHR call an agent makes should answer three questions from the record alone:
- Which human asked?
- Which agent did it?
- Which patient did it touch?
If your auth setup can't answer all three, you'll find out in a privacy review. There are three ways to give an agent an identity, and each one answers a different subset:
| Backend Services | SMART App Launch | On-behalf-of (your gateway) |
|---|
| Which human | None | The clinician | sub claim |
| Which agent | The client. In Epic, a shared service user | Only as the app's client | act claim |
| Which patient | Any the client policy allows | Launch context | Scoped in the token |
| Token lifetime | Spec: no more than 300 s | Oracle sample: 570 s | You set it. Keep it short |
| Longer access | Request a new token | online_access or offline_access refresh | Re-mint per task |
What system/ scopes actually grant
The SMART Backend Services spec is built for clients that run "autonomously (or semi-autonomously)" against a server that has already pre-authorized a defined scope of access. The client asks for system/ scopes. system/Observation.rs doesn't mean this patient's Observations. It means every Observation the client is pre-authorized to see.
The scopes spec says system/*.cruds gives "full access" relative to the server's client-specific policy. Neither SMART nor FHIR Core models that policy. It lives in whatever sits underneath. Oracle Health describes system/Patient.read plainly: "Read all patient records accessible to the given client using its own credentials."
That's god mode. One confused tool call or one prompt injection, and an LLM-driven agent can reach any chart that policy covers. I'd rather have an agent fail closed on a narrow token than trust the model to stay in its lane.
The audit trail is the second problem. Epic requires the customer's administrator to map every backend client ID to an Epic user account. Web service calls are audited against that account, and you can't get a token until the mapping is built. Epic also runs its end-user security checks against that background user, not the person using your app. In Epic's audit log, your agent shows up as one shared service user. The clinician and the model run don't appear.
Backend Services is still the right tool for real background work: a nightly lab reconciliation, a fax pipeline, remote monitoring where no clinician is present. Epic's own list of backend use cases looks like that: printing and faxing, remote patient monitoring, patient IVR, dashboards.
For those jobs, keep tokens short. The spec says expires_in SHOULD NOT exceed 300 seconds. One Epic sample response shows 3600, another shows 300, and Epic says it differs from the SMART profile "in some respects." Don't build around the long value. Ask for a new token per task.
Epic's client assertion rules are strict. Copy them even on servers that are more lenient:
{
"iss": "<client_id>",
"sub": "<client_id>",
"aud": "<token endpoint URL>",
"jti": "<unique, max 151 chars, never reused>",
"exp": "<no more than 5 min after iat/nbf>"
}
The JWT is one-time use. Sign it with a private key. Oracle Health's system flow also accepts HTTP Basic with a shared client secret, and Oracle only calls JWTs the right mode for Bulk Data. I'd use JWTs anyway. A shared secret behind an agent is a secret waiting to leak.
Borrow the clinician's token
If a clinician is in the loop, the agent should run on their access. Epic says it directly: apps "should always use the OAuth 2.0 token of the user who authorized the application when one is available."
A SMART App Launch token has two limits the backend token lacks:
- The user's own permissions cap it.
user/*.cruds is full access relative to that user, not to a client policy.
- Launch context narrows it. In Oracle Health's (Cerner) authorization spec, when a launch carries patient
123, every patient-context scope is constrained to patient 123. patient/*.rs then means read and search on this one chart.
That's the blast radius you want for an agent.
The cost is token lifetime. Oracle's sample user token response shows expires_in of 570 seconds, about nine and a half minutes. An agent that summarizes a long chart, calls a few tools and retries a failed step can run past that. Either fit each run inside one token, or check the expiry before every EHR call and refresh first.
The refresh scope you request matters:
online_access gets a refresh token that only works while the user is still online.
offline_access gets one that keeps working after the user leaves.
With Oracle, an offline_access refresh token lives until someone revokes it, or until it goes unused for three months. With Epic, the lifetime depends on per-app configuration and on the persistent access duration the user picked.
My take: an agent run tied to a clinician's session gets online_access, never offline_access. Offline refresh turns "Dr. Lee asked for a summary" into "this agent can act as Dr. Lee next Tuesday." If the work has to continue after the clinician leaves, it's background work. Give it a narrow backend client and call it what it is.
Two registration details will catch you here. Epic needs a separate app registration, with its own client IDs and API list, for each user context: patient, provider and backend. An agent that does both live and background work is two apps. Epic also says backend components must never share access tokens with the user's browser or device.
I'd go further and make the gateway the only thing that holds EHR tokens. The model gets typed tools. It never gets a bearer token.
On-behalf-of lives inside your boundary
The third setup records both parties. RFC 8693 (OAuth 2.0 Token Exchange) defines an act claim for delegation. The subject is the clinician. The current actor is the agent. Nested act claims form a chain, with the outermost entry as the current actor:
{
"sub": "Practitioner/8841",
"scope": "patient/Observation.rs patient/Condition.rs",
"patient": "123",
"exp": 1767225900,
"act": { "sub": "agent:chart-summarizer:run-7f3a" }
}
Only the current actor counts. The RFC says access control must consider the top-level claims and the current actor. Prior actors in the chain are "informational only." Treat the chain as audit data, not policy input.
Don't expect the EHR to do this for you. The Epic and Oracle Health authorization pages I checked never mention RFC 8693 or a token-exchange grant. Epic uses "token exchange" only for the normal code-for-token step in SMART launch.
So on-behalf-of lives inside your own boundary. The EHR sees the clinician's SMART token or your backend service user. Your gateway mints the internal token with sub and act and enforces policy on both: this agent may call this tool, and this clinician may see this patient. That gateway is often an MCP layer that exposes typed tasks instead of raw FHIR. The RFC suggests short lifetimes and narrow scopes for delegated tokens, which lines up with the 300-second backend guidance.
Narrow scopes to the task and the patient
Start from the task, not the app. A medication reconciliation agent doesn't need patient/*.rs. It needs MedicationRequest and maybe AllergyIntolerance.
SMART 2.0 lets you narrow a scope with search parameters. patient/Observation.rs?category=http://terminology.hl7.org/CodeSystem/observation-category|laboratory grants lab Observations only. The spec admits there is no community consensus yet on which search parameters servers must support. Request the narrow scope, then check what the server actually granted.
The vendors add their own limits:
- Epic grants scopes based on what you request plus the incoming APIs selected on the app registration page. You choose SMART v1 (
.read/.write) or v2 (CRUDS). Select every API the agent needs at registration, and nothing else.
- Oracle Health supports system-account access only on resources whose docs explicitly say so. Check each resource page before you design a background job around it.
The Epic and Oracle authorization pages I checked don't describe a way to limit a system/ token to one patient. Until you confirm otherwise for your server, enforce per-patient limits in your gateway.
Record the agent in Provenance
The EHR's audit log will say "Dr. Lee" or "the service user." Neither tells you a model run did the work. You have to write that down yourself.
For anything the agent writes, FHIR Provenance already has the right shape. agent.who can reference a Device, and onBehalfOf records who delegated. Model the agent as a Device and the clinician as onBehalfOf:
{
"resourceType": "Provenance",
"target": [{ "reference": "DocumentReference/summary-551" }],
"recorded": "2026-01-01T00:05:00Z",
"agent": [{
"who": { "reference": "Device/chart-summarizer-v3" },
"onBehalfOf": { "reference": "Practitioner/8841" }
}]
}
This works in R4 and R5, but the details differ. R4 allows a Device as onBehalfOf. R5 removes it, adds CareTeam, and requires who and onBehalfOf to differ. R5 also has patient and encounter fields for chart context. Agent as who with a human as onBehalfOf is valid in both. Don't put another agent or device in onBehalfOf, because R5 won't accept it.
For reads and everything else, log to your own store: run ID, prompt version, tool calls, patient, the human sub and the agent act. Link it to the Provenance ID for any write. Check the current AuditEvent spec for your server's FHIR version before you map anything into it. Until then, your own store is the source of truth.
This trace is also what lets you prove an access was in scope under consent rules. More on that in HIPAA Consent: What Engineers Actually Need to Know.
Registration details that bite
- Epic JKU rule. From the May 2026 version, backend apps must use a JWK Set URL instead of a static public key. Epic also recommends a unique credential per community member and separate keys for production and non-production.
- Epic JWT tokens. Customers can turn access tokens into JWTs, but Epic strongly advises against relying on that, because customers can switch it off. Don't decode a token whose
aud isn't your client ID.
- Oracle fhirUser. Oracle lists auditing under the user's identity as one reason to request
openid fhirUser. Request it, so your logs can name the human.
Epic registration and customer build are a project of their own. I covered the same gap for CDS Hooks in Epic. Read Epic's Application User Context docs and Oracle's per-resource pages before you decide what the EHR will handle for you and what your gateway has to do.
Which pattern to use
Clinician present: SMART App Launch with patient context, task-sized scopes and online_access. Each run fits inside the token.
No clinician: a backend client with JWT auth, registered for only the APIs it needs, with a fresh short token per task. Use it only for jobs you'd be comfortable describing to a privacy officer as "this service reads these resources for every patient."
Both: a gateway that holds the EHR tokens and carries sub and act. Every agent write gets a Provenance record with the agent's Device as agent.who and the clinician as onBehalfOf. The run ID goes in your own log.
It's the same instinct as keeping the LLM out of order-sign in prior auth. Give the model the narrowest authority that still does the job, and make sure you can name who it was working for.