SMART on FHIR: Authorization for Health Apps
SMART on FHIR is the open standard that defines how applications obtain authorized access to FHIR APIs using OAuth 2.0 and OpenID Connect. It specifies two primary launch contexts โ EHR launch, where the app is launched from within an EHR session and receives patient and encounter context via launch parameters, and standalone launch, where the app launches independently and negotiates context through scopes โ plus a standardized scope syntax that maps authorization grants to specific FHIR resource types and interactions [1]. Implementing SMART correctly is not optional for serious health apps: in the U.S., the SMART App Launch framework is part of the ONC ยง 170.315(g)(10) certification criterion for standardized patient and population APIs, and in our experience EHR vendor app marketplaces expect it as table stakes.
One versioning note before diving in: the scope syntax changed between SMART v1 and v2. The familiar patient/Observation.read and user/Patient.* style is SMART v1; SMART v2 replaces the suffixes with an in-order subset of .cruds (create, read, update, delete, search) โ so read-and-search access to observations becomes patient/Observation.rs, and v2 adds granular scopes with parameters such as patient/Observation.rs?category=...|vital-signs [2]. Servers advertise which syntax they accept via the permission-v1 and permission-v2 capabilities, and your app should check before it asks.
Step 1: Discovery
The flow begins with the app discovering the server's SMART configuration by appending /.well-known/smart-configuration to the FHIR base URL. The server must respond with a JSON document advertising its authorization and token endpoint URLs, supported capabilities, scopes, and PKCE code challenge methods (conveying this via the CapabilityStatement is now deprecated) [3]:
GET https://ehr.example.com/fhir/.well-known/smart-configuration HTTP/1.1
Host: ehr.example.com
{
"authorization_endpoint": "https://ehr.example.com/auth/authorize",
"token_endpoint": "https://ehr.example.com/auth/token",
"capabilities": ["launch-ehr", "launch-standalone", "sso-openid-connect",
"context-ehr-patient", "permission-v2", "permission-offline"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "fhirUser", "launch", "launch/patient",
"patient/*.rs", "offline_access"]
}
Step 2: Authorization Request
In an EHR launch, the EHR opens the app's launch URL with iss (the FHIR base URL) and launch (an opaque context handle) parameters; the app must echo that handle back. The app then redirects the browser to the authorization endpoint [1]:
GET https://ehr.example.com/auth/authorize?response_type=code&client_id=my-app-id&redirect_uri=https://myapp.example.org/callback&scope=launch+openid+fhirUser+patient/Observation.rs+patient/Patient.rs+offline_access&launch=xyz123&state=af0ifjsldkj&aud=https://ehr.example.com/fhir&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
Host: ehr.example.com
Two of these parameters are where the security guarantees live. PKCE is mandatory: the spec states that all SMART apps SHALL support Proof Key for Code Exchange, and servers SHALL support the S256 challenge method and SHALL NOT accept plain. The state value must be unguessable, single-use, and validated on return to the redirect URI to defeat CSRF and code-injection attacks โ and the redirect URI itself must exactly match a pre-registered value. For a standalone launch, drop the launch parameter and request context explicitly with scopes like launch/patient, which asks the server to establish a patient (typically via a patient picker) before completing authorization.
Step 3: Token Exchange and Context
The authorization server authenticates the user (or validates the EHR session), obtains consent, and redirects back with a short-lived authorization code, which the app exchanges via POST to the token endpoint along with its code_verifier. The token response carries the access token and the launch context [2]:
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid fhirUser patient/Observation.rs patient/Patient.rs offline_access",
"refresh_token": "eyJraWQiOi...",
"id_token": "eyJhbGciOi...",
"patient": "e8675309",
"encounter": "enc-4021",
"need_patient_banner": true,
"smart_style_url": "https://ehr.example.com/styles/smart.json"
}
Developers routinely trip on the context parameters. patient tells you whose chart you are in โ never infer it from anywhere else. need_patient_banner tells an embedded app whether it must render its own patient identification banner (the EHR isn't showing one). And always compare the granted scope against what you requested: servers may narrow grants, and assuming otherwise produces confusing 403s deep in your FHIR calls. Common token endpoint errors follow OAuth 2.0 semantics โ invalid_grant (expired/reused code, or a code_verifier that doesn't match), invalid_client (authentication failure), and invalid_scope โ and your app should surface them distinctly rather than as a generic login failure. For apps needing access after the session ends, request offline_access and use the refresh token; keep access token lifetimes short and rotate, especially in clinical contexts.
Backend Services: No User in the Loop
Not every client has a user to redirect. SMART Backend Services covers autonomous system-to-system access โ bulk data extraction into an analytics platform, a lab monitoring service generating alerts โ using the OAuth 2.0 client credentials flow with an asymmetric JWT assertion instead of a user-facing authorization step [4]. The client pre-registers its public key set (JWKS), signs a short-lived, one-time-use authentication JWT (RS384/ES384), POSTs it to the token endpoint as a client_assertion, and requests system/ scopes (e.g., system/Observation.rs) that the server has pre-authorized for that client. No secrets travel over the wire, and replay is prevented by jti uniqueness checks.
SMART Access Patterns at a Glance
| Pattern | Scope Prefix | User Involved? | Typical Use Case | Key Security Mechanism |
|---|---|---|---|---|
| EHR Launch | patient/, user/ |
Yes โ active EHR session | Clinician-facing app embedded in the EHR workflow | Launch handle + authorization code + PKCE (S256) |
| Standalone Launch | patient/, user/ |
Yes โ app-initiated login | Patient-facing app connecting to a portal/API | Authorization code + PKCE; context via launch/patient |
| Backend Services | system/ |
No โ pre-authorized client | Bulk export, analytics, monitoring services | Client credentials + asymmetric JWT assertion (JWKS) |
Whichever pattern you implement, test it against the Inferno SMART App Launch Test Kit [5], which provides suites for both SMART STU1 and STU2 covering discovery, standalone and EHR launch, token refresh, OpenID Connect ID token validation, and Backend Services authorization โ including the error paths (invalid scopes, mismatched state) that ad-hoc testing tends to skip.
Where CaboLabs Fits
Authorization is where health app projects meet their first production wall: scope negotiation quirks across vendor servers, PKCE and JWKS plumbing, certification test failures two weeks before a deadline. CaboLabs builds and reviews SMART on FHIR integrations โ app-side OAuth flows, server-side authorization infrastructure, and Inferno-driven conformance pipelines โ as part of our broader healthcare interoperability engineering practice across FHIR, HL7, and openEHR. The same delegation pattern is now reaching the openEHR world through the SMART on openEHR specification, and our openEHR-native clinical data repository Atomik gives you a standards-based persistence layer to put behind those authorized APIs.
If you're building a SMART app, exposing a secured FHIR API, or designing the authorization architecture for a clinical data platform, talk to us at cabolabs.com โ we'll help you get through certification the first time, not the third.
