How do SMART on FHIR scopes actually work?
SMART on FHIR is how apps launch inside (or alongside) an EHR and get scoped access to FHIR data via OAuth 2.0. The part teams misconfigure most is scopes — the strings that decide whether your app can read one patient's medications or write to the whole system. This guide covers the v1 and v2 scope syntax, patient vs user vs system context, launch context, and how to request least privilege so EHRs actually approve and your launches don't break.
The anatomy of a SMART scope
A SMART scope names a context, a resource, and an access level. In v1 the shape is context/Resource.access, for example patient/Observation.read (read Observations for the launch patient) or user/Encounter.read (read Encounters the user can see). The context prefix is the key security boundary.
- patient/ — access is limited to a single patient established at launch.
- user/ — access is scoped to whatever the authorizing user is permitted to see.
- system/ — no user; backend/bulk access via the client's own credentials (SMART Backend Services).
v1 vs v2 scope syntax
SMART v1 access was coarse: .read, .write, or .* per resource. SMART v2 replaced these with granular CRUDS permissions and optional query constraints, because "write" was often far more than an app needed.
- v1: patient/Observation.read, patient/Observation.write, patient/*.read
- v2: patient/Observation.rs (read + search), patient/Observation.cruds (full), with finer control than v1's blunt verbs.
- v2 also allows scope-level search parameters (e.g. constraining to a category), enabling true least privilege.
Reality check: many EHRs still speak v1, some speak v2, and some accept both. Your app should be able to request the syntax the server advertises — discovered from its .well-known/smart-configuration — rather than assuming one.
Launch context and the id_token
In an EHR launch, the EHR hands your app a launch parameter; you include launch and openid fhirUser scopes, and after authorization you receive context such as the patient id (and often encounter). The openid fhirUser scopes return an id_token identifying the logged-in clinician. Getting context right is what makes patient/ scopes resolve to the correct patient — without it, a patient-scoped app has no patient.
Offline access and refresh tokens
If your app needs to act after the user leaves (e.g. background sync), request the offline_access scope to receive a refresh token. Without it you only get access for the session. Only request it when you genuinely need persistence — it is exactly the kind of over-ask that slows EHR review.
Requesting least privilege (and getting approved)
- Ask only for the resources and access levels you use. patient/*.read when you read two resource types is a red flag in vendor review.
- Prefer read/search over write unless you truly write back; write scopes get more scrutiny and more liability.
- Discover the server's SMART capabilities and match its scope version instead of hard-coding v1 or v2.
- Handle the case where the server grants fewer scopes than requested — it can, and your app must degrade gracefully.
Backend (system) access
For server-to-server access with no user — bulk export, population jobs — use SMART Backend Services with system/ scopes and a signed JWT client assertion (asymmetric keys). This is a different trust model from user-facing launches and is what powers FHIR Bulk Data ($export).


