SMART on FHIR Scopes Should Narrow Authority, Never Create It
SMART on FHIR Scopes Should Narrow Authority, Never Create It
Version: dev
Source revision: 1aa13a38a06ae151d5a73713132b487d2a7c5216
Last verified: September 28, 2026
FHIR version: R4 4.0.1
Examples: synthetic data only
A SMART scope should never be a magic permission string.
If a user can read Observations and nothing else, then granting an app user/*.* should not turn that app into an administrator. If a backend service has no standing to read Patients, then system/Patient.read should not manufacture that standing. And if a patient-context scope has no launch patient, the safe answer is not “fall back to the user.” It is nothing.
That is the authorization rule Ilavrita now encodes directly:
SMART scopes narrow existing authority. They do not create authority.
The implementation is deliberately built as an intersection. AccessPolicy is compiled into concrete grants first. SMART scopes then remove grants, restrict them by type/action, or add a patient compartment. They never build a new grant from a scope string.
That sounds like a small architectural choice. It is actually the difference between treating OAuth scopes as consent boundaries and treating them as privileges.
Ilavrita is still a developer preview and should not hold patient data. This post describes the tested authorization boundary at the pinned revision above, not a production-readiness claim.
The dangerous implementation is the obvious one
A tempting SMART implementation looks like this:
scope: user/Observation.read
↓
grant: read Observation
That is simple, and wrong for a server that already has its own authorization system.
The scope only says what the app asked to do. It does not prove the person behind the app was allowed to do it in the first place.
In Ilavrita, authority already comes from the caller's standing in a Project. An AccessPolicy becomes a storage Scope: grants over resource types and actions, potentially narrowed by compartments, field filters, and projections.
SMART sits on top of that.
The code says it plainly in packages/authz/smart.go:
A SMART scope narrows what a person already holds. It never widens it.
That sentence is the design.
user/*.* means “everything this user already has”
The strongest test is not “can the parser understand a wildcard?” It is “can the wildcard escape the user's policy?”
The test TestAnAppNeverHoldsMoreThanThePersonWhoApprovedIt starts with a person who can read and search Observations, and nothing else.
The app asks for:
user/*.*
The result still contains only the two grants the person already had.
The wildcard expands against existing authority, not against every resource type the server happens to serve.
That distinction matters because Ilavrita currently declares 126 R4 resource types. A wildcard over the server catalog would be privilege expansion. A wildcard over the caller's grants is merely concise consent.
See packages/authz/smart_test.go.
The other direction matters too
Intersection has two sides.
A clinician might be allowed to read and write Observations and read Patients. If the app asks only for Observation read/search, it should not inherit the rest just because the user has it.
TestAnAppIsNarrowedToWhatItAskedFor covers that case.
The user's standing includes more than the app requests. The resulting app scope contains only the requested Observation read authority.
So the model is not:
effective = user authority
and it is not:
effective = SMART scopes
It is closer to:
effective = policy authority ∩ approved SMART scopes
For patient scopes there is one more intersection: the launch compartment.
A patient scope without a patient authorizes nothing
This is one of those places where permissive fallback feels convenient right up until it becomes a data leak.
Consider:
patient/Observation.read
The scope is not “read Observations.” It is “read Observations in the launched patient's context.”
If there is no launch patient, there is no set of resources described by the scope.
Ilavrita therefore returns no grants.
TestAPatientScopeWithNoLaunchAuthorizesNothing exists specifically to hold that behavior.
A clinician who can normally read many patients' Observations should not get that broad authority simply because patient context was missing. The absence of context is not permission to widen.
Patient context can narrow an otherwise broad policy
The ordinary case is the opposite.
Suppose a clinician's AccessPolicy can read Observations without a patient compartment. The app is launched for Patient/pat-1 and receives:
patient/Observation.read
Ilavrita takes the existing Observation-read grant and adds the patient compartment.
The app gets less than the clinician's general session, not more.
The test TestAPatientScopeConfinesAnUnconfinedGrant checks that the surviving grant is explicitly confined to Patient/pat-1.
There is also a mismatch test. If the person's standing is already confined to pat-mine and the app is launched for pat-theirs, the result is no authority at all.
The server does not choose one patient on the client's behalf.
SMART scopes cannot reach platform resources
FHIR authorization and platform administration should not collapse into one namespace.
Ilavrita's AccessPolicy system governs both FHIR-facing data access and internal platform resources. SMART does not.
TestNoScopeNamesAPlatformResource gives a caller a grant to read an AccessPolicy, then applies user/*.*.
The platform grant disappears.
This prevents a broad SMART scope from reaching the authorization machinery that decides what SMART can reach.
That is a useful boundary to test explicitly rather than relying on resource names never colliding.
Unsupported scope semantics are refused, not approximated
SMART's scope syntax can express distinctions Ilavrita's authorization model does not currently support exactly.
The server refuses those distinctions instead of quietly mapping them to something broader.
Examples at this revision include:
patient/Observation.rs?category=lab
Search-restricted SMART scopes are not applied yet.
Similarly, SMART v2 can distinguish create (c) from update (u), while Ilavrita's current authorization action groups those writes together. Granting one while internally allowing both would widen the request.
So a scope asking for create without update, or update without create, is refused.
This is less convenient than guessing. It is also much safer.
A client can react to an explicit refusal. It cannot react to a server silently changing the meaning of the scope.
Consent cannot widen what the authorization request offered
There are two moments where scope widening could happen: when the app asks, and when a person approves.
The approval endpoint cannot introduce a scope the server did not offer.
TestAnApprovalCannotWidenWhatWasOffered starts an authorization flow offering only:
user/Organization.read
and attempts to approve:
user/Organization.write
The request is refused.
The consent UI also receives two explicit lists: what is grantable and what is refused, including reasons. A deployment is therefore able to show a person what the server can actually grant instead of presenting the client's raw request as though every scope is valid.
And if no consent page is configured, the server authorizes nobody. It does not redirect to nowhere or silently skip consent.
Access tokens do not freeze policy forever
Ilavrita treats a SMART access token as a session, not as a bag of copied permissions.
That choice matters when an administrator changes policy after a token was minted.
The repository documentation describes the intended boundary: each request is narrowed against what the principal's standing says now, rather than against a snapshot of authority stored inside the token at authorization time.
The token carries the approved SMART narrowing. The underlying authority still comes from current policy.
That avoids a common stale-authorization problem: revoke a user's access, but an hour-old token keeps a historical copy of privileges until expiry.
The trade-off is that authorization stays stateful. For this architecture, that is intentional.
Refresh must preserve narrowing, not re-ask for power
A refresh token should extend the approved grant. It should not let the client renegotiate it.
The test TestARefreshMintsANewSessionCarryingTheSameApproval verifies that the refreshed access token carries the same scope as the original.
It then tries an operation outside that approval.
The write still fails.
Ilavrita also rotates refresh tokens on every use. Replaying a spent refresh token invalidates the grant, including sessions minted from it. That behavior is security-sensitive, but it is separate from the core authorization argument: refresh extends a narrowing; it does not widen it.
Backend Services follows the same rule
SMART Backend Services has no human approval step, so it is easy to accidentally treat a system/ scope as self-authorizing.
Ilavrita does not.
A backend service is a registered principal with its own standing. system/Observation.read narrows that standing just as a user scope narrows a person's standing.
The test TestABackendServiceScopeNarrowsAgainstWhoeverIsAsking gives a service read/write access to Observation and read access to Condition. The service requests only:
system/Observation.read
The resulting scope contains only Observation read.
Then the test asks for Patient read using a service standing that has no Patient grant. Nothing is produced.
At the OAuth surface, Backend Services authentication uses client_credentials with private_key_jwt. The positive route test verifies that a service granted only read cannot write even when its underlying standing is broader.
No person. No consent. Still no scope-created authority.
Some scopes describe the token, not FHIR resources
Not every SMART/OIDC scope should pass through the FHIR resource narrowing parser.
Examples include:
offline_access
online_access
launch/patient
openid
fhirUser
These scopes describe refresh behavior, launch context, or identity-token claims.
A previous implementation bug tried to treat some of them as resource restrictions. That produced access tokens that failed FHIR requests with server errors.
The current code carries a closed list of scopes that “narrow nothing” and deliberately ignores them in resource authorization.
Why a closed list?
Because ignoring every unknown scope would be another form of silent widening. A scope the server does not understand still fails the launch.
The test TestAScopeThatNarrowsNothingDoesNotBreakEverythingElse is a positive control: it verifies that adding offline_access, online_access, or launch/patient does not break FHIR operations the approval actually granted.
That positive assertion matters. “The write did not happen” is satisfied by both a correct 403 and an accidental 500.
OpenID Connect follows least disclosure too
openid asks the server to identify the signed-in subject.
fhirUser additionally asks which FHIR resource represents that person.
Those are not synonyms.
The OpenID tests verify that a token granted only openid does not receive a fhirUser claim. When fhirUser is granted, the claim must be an absolute URL pointing to an allowed FHIR identity type such as Patient, Practitioner, RelatedPerson, or Person.
Again, the narrower request wins.
OpenID Connect is only advertised when the deployment has the signing material required to issue it. The current roadmap still records EHR launch as incomplete; standalone SMART launch and Backend Services are the implemented paths.
What Inferno proved — and what it did not
The repository records an external SMART App Launch STU2.2 Inferno run against this build.
The documented passing groups include:
- SMART discovery, including CORS,
- standalone launch,
- token refresh,
- Backend Services authorization,
- OpenID Connect and
fhirUserresolution.
The remaining failures in that recorded run were TLS-version checks against the plaintext local test deployment.
That is useful evidence, but it is not a production certification.
The run is also not automated in the same way as the Go suite. It requires a seeded test database, a consent page, and browser-driven launch tooling.
The honest claim is therefore narrow: the recorded external run exercised these SMART flows and found no non-TLS failures at that revision.
Reproducing the authorization claims
Pin the revision first:
git clone https://github.com/Ilavrita/Ilavrita.git
cd Ilavrita
git checkout 1aa13a38a06ae151d5a73713132b487d2a7c5216
make bootstrap
Run the authorization core:
go test ./packages/authz -run 'TestAnAppNeverHoldsMoreThanThePersonWhoApprovedIt|TestAnAppIsNarrowedToWhatItAskedFor|TestAPatientScopeWithNoLaunchAuthorizesNothing|TestABackendServiceScopeNarrowsAgainstWhoeverIsAsking' -count=1
Then the OAuth surface:
go test ./apps/ilavrita -run 'TestAnApprovalCannotWidenWhatWasOffered|TestAScopeThatNarrowsNothingDoesNotBreakEverythingElse|TestABackendServiceAuthenticatesByTheKeyItSignedWith|TestARefreshMintsANewSessionCarryingTheSameApproval' -count=1
And the identity boundary:
go test ./apps/ilavrita -run 'TestFHIRUserIsWithheldFromAnApprovalThatOnlyAskedForOpenID|TestTheFHIRUserClaimNamesTheResourceThisPersonIs' -count=1
Verification note: I attempted this clean-clone path while preparing the post. The publishing runtime could not resolve github.com, so these commands were not executed here. The pinned implementation and named tests were inspected through the connected GitHub repository API instead. No fresh local test execution is claimed.
Documentation drift still matters
The current source and tests are clearer than some older prose in the repository.
The current README and roadmap describe standalone SMART launch, Backend Services, refresh, OpenID Connect, and the policy-narrowing model as implemented. docs/known-limitations.md also records the same current capabilities, but it contains older stale sections elsewhere for unrelated features.
The security documentation still carries older wording around other conformance gaps as well.
For this article, capability claims come from the pinned implementation and tests, with README/roadmap/known-limitations used only where they agree with them.
That is the rule we want the project itself to converge on: documentation should summarize executable evidence, not compete with it.
The practical rule
OAuth scopes are often described as permissions. That shorthand is dangerous.
In an application server that already has a policy system, scopes should be thought of as requested and approved restrictions on an existing principal.
That framing makes several decisions obvious:
A wildcard cannot exceed policy. Missing patient context cannot fall back to broader access. Backend-service scopes cannot grant rights the service registration never had. Refresh cannot enlarge approval. Identity scopes cannot leak claims that were never requested.
The code becomes easier to review because the security argument has one shape:
nothing coming out of SMART authorization may be more powerful
than the standing that went into it
That is the property worth preserving as Ilavrita's SMART surface grows.
EHR launch, richer SMART v2 restrictions, and additional interoperability work can add capability later. They should not change where authority comes from.
Evidence
- SMART authorization implementation
- SMART authorization tests
- OAuth route tests
- OpenID route tests
- SMART scope design
- Current README
- Current roadmap
- Known limitations
- SMART App Launch specification
- OAuth 2.0 RFC 6749
For the rest of the current boundary, see docs, known limitations, and the roadmap.
