SMART on FHIR App Development Tutorial: Build EHR-Integrated Healthcare Apps (2026)
Key Takeaways
- SMART on FHIR (Substitutable Medical Applications, Reusable Technologies) is the standard framework for building third-party applications that launch from within EHR systems and access clinical data through FHIR APIs. It is supported by Epic, Oracle Health (Cerner), Athenahealth, and virtually every certified EHR in the United States.
- SMART on FHIR combines HL7 FHIR R4 for data access with OAuth 2.0 for authorization, enabling apps to securely access patient data within the clinician’s existing workflow — no separate login, no context switching, no manual patient lookup.
- A single SMART on FHIR app can work across multiple EHR platforms. Build once, deploy to Epic, Oracle Health, Athenahealth, and any other SMART-enabled EHR. This cross-platform portability is the core value proposition for health tech developers.
- The 21st Century Cures Act and ONC certification requirements mandate SMART on FHIR support for certified EHRs, making this framework the regulatory standard — not just a technical preference.
- Taction Software’s SMART on FHIR implementations consistently show that apps built on this framework achieve 60–70% faster deployment across multiple EHR platforms compared to building custom integrations for each EHR separately.
What Is SMART on FHIR
SMART on FHIR is an open-standards-based framework that allows developers to build applications that integrate with any EHR system supporting the SMART specification. It was originally developed at Boston Children’s Hospital and Harvard Medical School, and is now maintained by HL7 International as a core part of the FHIR ecosystem.
The framework solves a fundamental problem in healthcare IT: before SMART on FHIR, building an application that worked inside an EHR required proprietary integration with each vendor. An app built for Epic could not run in Cerner without a complete rebuild. SMART on FHIR eliminates this by providing a standardized launch protocol, authorization mechanism, and data access layer.
What SMART provides:
- A standardized way to launch apps from within an EHR (launch framework)
- A standardized way to authenticate and authorize data access (OAuth 2.0 profiles)
- A standardized data format and query language (FHIR R4)
- Context sharing between the EHR and the app (which patient, which encounter, which user)
What SMART does NOT provide:
- A UI framework (you build your own interface)
- A hosting platform (you host your own application)
- A database (you use FHIR APIs to access EHR data at runtime)
- Write access by default (write permissions require additional authorization)
How SMART on FHIR Works
The SMART on FHIR workflow has three phases:
Phase 1: Discovery
Your app discovers the EHR’s FHIR server capabilities by fetching the server’s metadata:
GET {fhir-base-url}/.well-known/smart-configuration
This returns a JSON document containing:
authorization_endpoint— where to send the user for authenticationtoken_endpoint— where to exchange authorization codes for access tokensscopes_supported— which FHIR scopes the server supportscapabilities— which SMART features are supported (launch-ehr, launch-standalone, etc.)
Alternatively, you can fetch the FHIR CapabilityStatement:
GET {fhir-base-url}/metadata
This returns the server’s FHIR capabilities including supported resources, search parameters, and security extensions containing the OAuth endpoints.
Why discovery matters: Different EHR systems have different OAuth endpoints, different supported scopes, and different capabilities. Your app must dynamically discover these rather than hardcoding them. This is what makes your app portable across EHR platforms.
Phase 2: Authorization
Your app requests authorization to access data on behalf of the user:
- Redirect the user’s browser to the
authorization_endpointwith your requested scopes - The EHR’s authorization server authenticates the user (clinician or patient)
- The user consents to the requested data access
- The authorization server redirects back to your app with an authorization code
- Your app exchanges the authorization code for an access token at the
token_endpoint
Phase 3: Data Access
Your app uses the access token to make FHIR API calls:
GET {fhir-base-url}/Patient/{id} Authorization: Bearer {access-token}
The access token is scoped to specific FHIR resources and specific patients based on the launch context and requested scopes. Your app cannot access data outside the authorized scope.
SMART Launch Framework
The SMART launch framework defines how your app receives context from the EHR — which patient is selected, which encounter is active, which user is logged in.
Launch Context Parameters
When an EHR launches your app, it provides context through the OAuth token response:
| Context Parameter | Description | Example Value |
|---|---|---|
patient | FHIR Patient resource ID | “12345” |
encounter | FHIR Encounter resource ID | “67890” |
fhirUser | FHIR resource URL of the logged-in user | “Practitioner/11111” |
need_patient_banner | Whether EHR is already showing patient info | true/false |
smart_style_url | URL to EHR’s style guide for visual consistency | URL string |
tenant | Tenant identifier for multi-tenant systems | “hospital-a” |
patient is the most critical parameter. It tells your app which patient’s data to access. In an EHR launch, this is automatically set to the patient whose chart the clinician has open. In a standalone launch, the user selects a patient during the authorization flow.
Launch URL Registration
When registering your app with an EHR, you provide:
- Launch URL — the URL the EHR calls when launching your app
- Redirect URL — where the authorization server sends the user after authentication
- Scopes — which FHIR data your app needs access to
The EHR appends a launch parameter and an iss (issuer) parameter to your Launch URL:
https://your-app.com/launch?iss=https://fhir.hospital.com/r4&launch=abc123
Your app uses the iss to discover the authorization endpoint and the launch to correlate the session with the EHR context.
EHR Launch vs Standalone Launch
EHR Launch
How it works: A clinician is working in the EHR, has a patient chart open, and clicks a button or menu item to launch your app. The EHR passes the patient context to your app automatically.
Authorization URL parameters:
{authorization_endpoint}?
response_type=code&
client_id={your-client-id}&
redirect_uri={your-redirect-url}&
scope=launch openid fhirUser patient/*.read&
state={random-state-value}&
aud={fhir-base-url}&
launch={launch-token-from-ehr}
Key parameter: launch — this token links the authorization request to the specific EHR session and patient context.
Best for: Clinical workflow applications, clinical decision support tools, order entry assistants, documentation aids, prior authorization tools — any app that needs to operate in the context of a specific patient encounter.
Standalone Launch
How it works: Your app launches independently — from a browser bookmark, a mobile app icon, or any other entry point outside the EHR. The user authenticates and then selects a patient.
Authorization URL parameters:
{authorization_endpoint}?
response_type=code&
client_id={your-client-id}&
redirect_uri={your-redirect-url}&
scope=launch/patient openid fhirUser patient/*.read&
state={random-state-value}&
aud={fhir-base-url}
Key difference: Instead of the launch parameter, the scope includes launch/patient, which tells the authorization server to present a patient picker during the authorization flow.
Best for: Patient-facing apps, administrative tools, reporting applications, data analytics dashboards — apps that do not need to be embedded within the EHR clinical workflow.
Which Launch Type Should You Support?
Most apps should support both launch types. EHR launch for clinical workflow integration and standalone launch for use outside the EHR. The authorization logic is nearly identical — the only difference is how patient context is obtained (automatically from EHR vs patient picker).
OAuth 2.0 Authorization Flow
SMART on FHIR uses OAuth 2.0 Authorization Code Grant with Proof Key for Code Exchange (PKCE) for public clients.
Step-by-Step Authorization Flow
Step 1: Discover OAuth endpoints
const smartConfig = await fetch(`${fhirBaseUrl}/.well-known/smart-configuration`)
.then(r => r.json());
const authUrl = smartConfig.authorization_endpoint;
const tokenUrl = smartConfig.token_endpoint;
Step 2: Generate PKCE code verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const hash = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier));
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
Step 3: Redirect to authorization endpoint
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in session for later use
const authParams = new URLSearchParams({
response_type: 'code',
client_id: YOUR_CLIENT_ID,
redirect_uri: YOUR_REDIRECT_URI,
scope: 'launch openid fhirUser patient/*.read',
state: generateRandomState(),
aud: fhirBaseUrl,
launch: launchToken, // only for EHR launch
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${authUrl}?${authParams}`;
Step 4: Handle the redirect callback
After the user authenticates, the authorization server redirects to your redirect URI with an authorization code:
https://your-app.com/callback?code=AUTH_CODE&state=YOUR_STATE
Step 5: Exchange code for access token
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: YOUR_REDIRECT_URI,
client_id: YOUR_CLIENT_ID,
code_verifier: storedCodeVerifier
})
}).then(r => r.json());
const accessToken = tokenResponse.access_token;
const patientId = tokenResponse.patient; // patient context
const encounterId = tokenResponse.encounter; // encounter context (if available)
const idToken = tokenResponse.id_token; // user identity (if openid scope requested)
Step 6: Use the access token for FHIR API calls
const patient = await fetch(`${fhirBaseUrl}/Patient/${patientId}`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
}).then(r => r.json());
Token Refresh
Access tokens are short-lived (5–60 minutes depending on the EHR). If the token response includes a refresh_token, use it to obtain new access tokens without re-authorization:
const refreshResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
client_id: YOUR_CLIENT_ID
})
}).then(r => r.json());
FHIR Scopes and Permissions
SMART scopes define what data your app can access. Scopes follow a specific format:
Patient-Level Scopes
Format: patient/{resource-type}.{permission}
patient/Patient.read— read the patient’s demographic datapatient/Observation.read— read lab results and vitalspatient/MedicationRequest.read— read medication orderspatient/Condition.read— read diagnoses and problemspatient/AllergyIntolerance.read— read allergiespatient/Procedure.read— read procedurespatient/Immunization.read— read immunizationspatient/DocumentReference.read— read clinical documentspatient/Encounter.read— read encounter historypatient/*.read— read all resource types for the patientpatient/Observation.write— write observations (vitals, patient-reported data)
User-Level Scopes
Format: user/{resource-type}.{permission}
User-level scopes grant access based on the logged-in user’s permissions within the EHR — not limited to a single patient.
user/Patient.read— read any patient the user has access touser/Practitioner.read— read practitioner informationuser/*.read— read all resources the user has permission to access
System-Level Scopes
Format: system/{resource-type}.{permission}
System-level scopes are for backend services (no user interaction). Used with the Client Credentials grant.
system/Patient.read— read all patientssystem/*.read— read all resourcessystem/Group.read— required for Bulk Data Export
Special Scopes
launch— indicates EHR launch (receive launch context)launch/patient— indicates standalone launch (patient picker)openid— request OpenID Connect identity tokenfhirUser— include the FHIR user reference in the token
Scope Best Practices
Request minimum necessary scopes. Only request access to the specific resource types your app needs. An app that only displays medications should request patient/MedicationRequest.read, not patient/*.read. EHR authorization servers may reject overly broad scope requests, and patients are more likely to consent to specific, limited access.
Separate read and write scopes. If your app only needs to read data, do not request write scopes. Write access requires additional review and approval from EHR vendors.
Building Your First SMART on FHIR App
Prerequisites
- A web application (React, Angular, Vue, or vanilla JavaScript)
- A registered application on your target EHR’s developer portal
- Access to the EHR’s sandbox environment
- Understanding of OAuth 2.0 and FHIR R4
Using the SMART on FHIR JavaScript Client Library
The fhirclient JavaScript library handles the SMART launch flow, authorization, and FHIR API calls. It is the recommended approach for web-based SMART apps.
Install:
npm install fhirclient
EHR Launch — launch.html:
import FHIR from 'fhirclient';
FHIR.oauth2.authorize({
clientId: 'your-client-id',
scope: 'launch openid fhirUser patient/Patient.read patient/Observation.read patient/MedicationRequest.read patient/Condition.read patient/AllergyIntolerance.read',
redirectUri: '/app'
});
App page — app.html:
import FHIR from 'fhirclient';
async function loadApp() {
const client = await FHIR.oauth2.ready();
// Get patient demographics
const patient = await client.patient.read();
console.log(`Patient: ${patient.name[0].given[0]} ${patient.name[0].family}`);
console.log(`DOB: ${patient.birthDate}`);
console.log(`Gender: ${patient.gender}`);
// Get active conditions
const conditions = await client.request(
`Condition?patient=${client.patient.id}&clinical-status=active`
);
// Get active medications
const medications = await client.request(
`MedicationRequest?patient=${client.patient.id}&status=active`
);
// Get recent lab results
const labs = await client.request(
`Observation?patient=${client.patient.id}&category=laboratory&_sort=-date&_count=20`
);
// Get allergies
const allergies = await client.request(
`AllergyIntolerance?patient=${client.patient.id}`
);
// Render data in your UI
renderPatientSummary(patient, conditions, medications, labs, allergies);
}
loadApp();
Handling FHIR Bundle Responses
FHIR search queries return Bundle resources containing the matching entries:
const bundle = await client.request(
`Condition?patient=${client.patient.id}&clinical-status=active`
);
if (bundle.entry) {
const conditions = bundle.entry.map(e => e.resource);
conditions.forEach(condition => {
const code = condition.code?.coding?.[0]?.display || 'Unknown';
const icd10 = condition.code?.coding?.find(c =>
c.system === 'http://hl7.org/fhir/sid/icd-10-cm'
)?.code;
console.log(`${code} (${icd10})`);
});
}
Handling Pagination
For large result sets, follow the Bundle’s pagination links:
async function getAllResults(client, url) {
let results = [];
let bundle = await client.request(url);
while (bundle) {
if (bundle.entry) {
results = results.concat(bundle.entry.map(e => e.resource));
}
const nextLink = bundle.link?.find(l => l.relation === 'next');
bundle = nextLink ? await client.request(nextLink.url) : null;
}
return results;
}
const allLabs = await getAllResults(client,
`Observation?patient=${client.patient.id}&category=laboratory`
);
Reading Clinical Data with FHIR APIs
Patient Demographics
const patient = await client.patient.read();
const name = `${patient.name[0].given.join(' ')} ${patient.name[0].family}`;
const dob = patient.birthDate;
const gender = patient.gender;
const mrn = patient.identifier?.find(id =>
id.type?.coding?.[0]?.code === 'MR'
)?.value;
const phone = patient.telecom?.find(t => t.system === 'phone')?.value;
const email = patient.telecom?.find(t => t.system === 'email')?.value;
const address = patient.address?.[0];
Vital Signs
const vitals = await client.request(
`Observation?patient=${client.patient.id}&category=vital-signs&_sort=-date&_count=10`
);
vitals.entry?.forEach(e => {
const obs = e.resource;
const type = obs.code?.coding?.[0]?.display;
const value = obs.valueQuantity?.value;
const unit = obs.valueQuantity?.unit;
const date = obs.effectiveDateTime;
console.log(`${type}: ${value} ${unit} (${date})`);
});
Lab Results
const labs = await client.request(
`Observation?patient=${client.patient.id}&category=laboratory&_sort=-date&_count=50`
);
labs.entry?.forEach(e => {
const obs = e.resource;
const testName = obs.code?.coding?.[0]?.display;
const loinc = obs.code?.coding?.find(c =>
c.system === 'http://loinc.org'
)?.code;
const value = obs.valueQuantity?.value || obs.valueString;
const unit = obs.valueQuantity?.unit || '';
const refLow = obs.referenceRange?.[0]?.low?.value;
const refHigh = obs.referenceRange?.[0]?.high?.value;
const abnormal = obs.interpretation?.[0]?.coding?.[0]?.code; // H, L, A, etc.
const date = obs.effectiveDateTime;
});
Medications
const meds = await client.request(
`MedicationRequest?patient=${client.patient.id}&status=active`
);
meds.entry?.forEach(e => {
const med = e.resource;
const drugName = med.medicationCodeableConcept?.coding?.[0]?.display
|| med.medicationCodeableConcept?.text;
const rxnorm = med.medicationCodeableConcept?.coding?.find(c =>
c.system === 'http://www.nlm.nih.gov/research/umls/rxnorm'
)?.code;
const dosage = med.dosageInstruction?.[0]?.text;
const prescriber = med.requester?.display;
const dateWritten = med.authoredOn;
});
Clinical Notes (OpenNotes)
const notes = await client.request(
`DocumentReference?patient=${client.patient.id}&type=clinical-note&_sort=-date&_count=10`
);
notes.entry?.forEach(e => {
const doc = e.resource;
const noteType = doc.type?.coding?.[0]?.display;
const date = doc.date;
const author = doc.author?.[0]?.display;
// Get the actual note content
const attachment = doc.content?.[0]?.attachment;
if (attachment?.data) {
const noteText = atob(attachment.data); // Base64 decode
} else if (attachment?.url) {
// Fetch the note content from the URL
const noteContent = await client.request(attachment.url);
}
});
Writing Data Back to the EHR
Write operations require specific write scopes and additional approval from EHR vendors. Common write use cases:
Creating an Observation (Patient-Reported Data)
const observation = {
resourceType: 'Observation',
status: 'final',
category: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/observation-category',
code: 'vital-signs',
display: 'Vital Signs'
}]
}],
code: {
coding: [{
system: 'http://loinc.org',
code: '85354-9',
display: 'Blood pressure panel'
}]
},
subject: { reference: `Patient/${client.patient.id}` },
effectiveDateTime: new Date().toISOString(),
component: [
{
code: { coding: [{ system: 'http://loinc.org', code: '8480-6', display: 'Systolic BP' }] },
valueQuantity: { value: 120, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' }
},
{
code: { coding: [{ system: 'http://loinc.org', code: '8462-4', display: 'Diastolic BP' }] },
valueQuantity: { value: 80, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm[Hg]' }
}
]
};
const created = await client.create(observation);
Creating a DocumentReference
const docRef = {
resourceType: 'DocumentReference',
status: 'current',
type: {
coding: [{
system: 'http://loinc.org',
code: '34133-9',
display: 'Summary of episode note'
}]
},
subject: { reference: `Patient/${client.patient.id}` },
date: new Date().toISOString(),
content: [{
attachment: {
contentType: 'text/plain',
data: btoa('Clinical assessment summary text here...')
}
}]
};
const created = await client.create(docRef);
Write Operation Considerations
- Not all EHR platforms support writes for all resource types
- Write scopes must be explicitly requested and approved
- Data validation is stricter for writes — ensure correct coding systems, required fields, and proper references
- Test writes extensively in sandbox before production
- Some EHRs require writes to go through clinical review before appearing in the chart
CDS Hooks Integration
CDS Hooks extend SMART on FHIR by enabling your application to provide clinical decision support at specific workflow trigger points within the EHR.
How CDS Hooks Work
- The EHR registers your CDS service endpoint
- At defined workflow points (hooks), the EHR sends a request to your service with context data
- Your service evaluates the context and returns decision support cards
- The EHR displays your cards to the clinician within their workflow
Supported Hook Types
| Hook | Trigger | Use Case |
|---|---|---|
patient-view | Clinician opens a patient chart | Care gap alerts, risk scores, prior visit summaries |
order-select | Clinician selects an order | Drug interaction warnings, formulary checks, prior auth requirements |
order-sign | Clinician signs orders | Final validation, cost alerts, guideline compliance checks |
encounter-start | New encounter begins | Screening reminders, protocol suggestions |
encounter-discharge | Discharge workflow initiated | Discharge checklist, follow-up scheduling reminders |
appointment-book | Appointment being scheduled | Pre-visit preparation reminders, eligibility checks |
CDS Hooks Service Endpoint
Your service exposes a discovery endpoint and a hook endpoint:
Discovery: GET https://your-cds-service.com/cds-services
{
"services": [{
"hook": "patient-view",
"title": "Diabetes Risk Assessment",
"description": "Evaluates patient risk factors for Type 2 diabetes",
"id": "diabetes-risk",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"conditions": "Condition?patient={{context.patientId}}&clinical-status=active",
"labs": "Observation?patient={{context.patientId}}&category=laboratory&code=4548-4&_sort=-date&_count=1"
}
}]
}
Hook invocation: POST https://your-cds-service.com/cds-services/diabetes-risk
The EHR sends context and prefetched data. Your service responds with cards:
{
"cards": [{
"summary": "HbA1c trending upward — consider diabetes screening",
"detail": "Patient's last HbA1c was 6.2% (3 months ago). Combined with BMI of 31 and family history, this patient meets ADA screening criteria.",
"indicator": "warning",
"source": {
"label": "Taction Diabetes Risk Engine",
"url": "https://your-app.com/info"
},
"suggestions": [{
"label": "Order HbA1c test",
"actions": [{
"type": "create",
"description": "Order HbA1c",
"resource": {
"resourceType": "ServiceRequest",
"code": { "coding": [{ "system": "http://loinc.org", "code": "4548-4" }] },
"subject": { "reference": "Patient/12345" }
}
}]
}],
"links": [{
"label": "View full risk assessment",
"url": "https://your-app.com/launch",
"type": "smart"
}]
}]
}
CDS Hooks Best Practices
Minimize alert fatigue. Only return cards when clinically significant. If your service returns cards for every patient, clinicians will ignore all of them. Target a card rate below 15–20% of patient encounters.
Keep cards concise. The summary should be actionable in one sentence. Use detail for supporting evidence. Clinicians spend seconds, not minutes, reviewing CDS cards.
Provide actionable suggestions. Cards with specific, one-click actions (order a test, update a diagnosis) are acted upon 3–5x more frequently than informational-only cards.
Respond fast. EHRs impose timeout limits on CDS Hook calls (typically 5–10 seconds). Your service must respond within this window. Pre-compute risk scores and cache results where possible.
SMART on FHIR with Epic
Epic’s SMART on FHIR implementation is the most widely deployed in the US hospital market.
Registration: Register your app through Epic’s App Orchard/Showroom. Specify SMART launch type, required scopes, and redirect URIs.
Epic-specific considerations:
- Epic uses its own authorization server — OAuth endpoints are Epic-specific per customer instance
- Epic supports both EHR launch (from Hyperspace/MyChart) and standalone launch
- Epic’s FHIR server enforces strict scope validation — requesting unsupported scopes will cause authorization failure
- Epic returns
patient,encounter, andfhirUsercontext in the token response - Epic supports CDS Hooks for
patient-view,order-select, andorder-sign - Epic’s sandbox (available through App Orchard) contains synthetic data for testing
Epic-specific scopes: Epic supports standard SMART scopes plus Epic-specific extensions for certain resource types. Always check Epic’s FHIR documentation for the exact scope syntax for each resource.
SMART on FHIR with Oracle Health
Oracle Health (Cerner) Millennium supports SMART on FHIR through its Ignite API platform.
Registration: Register through the Oracle Health developer portal. Create an application with SMART launch capabilities.
Oracle Health-specific considerations:
- Millennium’s authorization server handles OAuth flows
- Oracle Health supports both EHR launch (from PowerChart) and standalone launch
- Oracle Health’s multi-tenant sandbox provides immediate access for development
- Oracle Health returns Millennium-specific identifiers in the patient context — map these to your application’s data model
- CDS Hooks support is available for
patient-view,order-select, andorder-sign - Oracle Health is transitioning API management to Oracle Cloud — new apps may encounter Oracle API Gateway requirements
SMART on FHIR with Athenahealth
Athenahealth supports SMART on FHIR alongside its proprietary REST APIs.
Registration: Register through the athenahealth developer portal. Configure SMART launch for Marketplace integration.
Athenahealth-specific considerations:
- Athenahealth’s cloud-native architecture means consistent SMART behavior across all customer practices
- Athenahealth supports EHR launch (from athenaOne) and standalone launch
- Athenahealth’s FHIR implementation covers clinical and some administrative resources — revenue cycle data is primarily accessible through the proprietary REST API
- No per-site configuration required — SMART app works for all athenahealth customers once approved
- Marketplace embedded apps can combine SMART launch with athenahealth’s proprietary embedding framework
Backend Services and System-Level Access
For applications that need to access FHIR data without user interaction — background synchronization, analytics pipelines, population health tools — SMART defines the Backend Services authorization profile.
Client Credentials with Asymmetric Keys
Backend services authenticate using JWT assertions signed with a private key:
const jwt = createSignedJWT({
iss: YOUR_CLIENT_ID,
sub: YOUR_CLIENT_ID,
aud: tokenUrl,
exp: Math.floor(Date.now() / 1000) + 300,
jti: generateUniqueId()
}, YOUR_PRIVATE_KEY);
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'system/*.read',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: jwt
})
}).then(r => r.json());
Bulk Data Export
Backend services can use the FHIR Bulk Data Export specification to export large datasets:
// Initiate export
const exportResponse = await fetch(`${fhirBaseUrl}/Patient/$export`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/fhir+json',
'Prefer': 'respond-async'
}
});
const statusUrl = exportResponse.headers.get('Content-Location');
// Poll for completion
let status;
do {
await new Promise(resolve => setTimeout(resolve, 10000)); // wait 10 seconds
status = await fetch(statusUrl, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
} while (status.status === 202);
// Download results
const result = await status.json();
const fileUrls = result.output.map(f => f.url);
Backend services are essential for healthcare data analytics platforms and remote patient monitoring systems that need ongoing data access without clinician interaction.
Security and HIPAA Considerations
Access Token Security
- Never expose access tokens in client-side JavaScript logs, URLs, or browser storage
- Use short-lived access tokens (match the EHR’s token lifetime)
- Implement token refresh logic for long-running sessions
- Revoke tokens when the user logs out or the session ends
- Use HTTPS for all API calls — never transmit tokens over unencrypted connections
PKCE Requirements
Public clients (browser-based apps, mobile apps) must use PKCE (Proof Key for Code Exchange) to prevent authorization code interception attacks. The code_verifier and code_challenge parameters in the OAuth flow implement this protection.
Confidential clients (server-side apps with a client secret) should also use PKCE as a defense-in-depth measure.
Data Handling
- Do not store PHI in the browser beyond the current session
- If your app caches FHIR data locally, encrypt it and clear it when the session ends
- Audit log all FHIR API calls your app makes (which user, which patient, which data)
- Implement automatic session timeout consistent with HIPAA requirements (15–30 minutes of inactivity)
CORS and Content Security Policy
EHR FHIR servers typically support CORS for browser-based SMART apps. However, some EHR implementations require server-side API calls (your backend proxies FHIR requests) rather than direct browser-to-FHIR calls. Test CORS behavior in each EHR’s sandbox.
If your SMART app runs in an iframe within the EHR, Content Security Policy headers may restrict which external resources your app can load. Test iframe behavior early in development.
Testing and Debugging
Public SMART on FHIR Sandboxes
| Sandbox | URL | Description |
|---|---|---|
| SMART Health IT Sandbox | launch.smarthealthit.org | Open sandbox for testing SMART launch flows |
| Logica Health Sandbox | sandbox.logicahealth.org | Full FHIR R4 sandbox with synthetic data |
| Epic App Orchard Sandbox | Through Epic developer portal | Epic-specific FHIR sandbox |
| Oracle Health Sandbox | Through Cerner Code portal | Millennium-specific sandbox |
| Athenahealth Preview | Through athenahealth developer portal | Athenahealth-specific sandbox |
Debugging Tools
SMART App Launcher: The SMART Health IT Launcher (launch.smarthealthit.org) allows you to simulate EHR launches without an actual EHR. Configure patient context, user context, and supported scopes to test your app’s launch flow.
FHIR Request Inspector: Log all FHIR API requests and responses during development. The fhirclient library supports debug mode that logs HTTP requests.
Token Decoder: Use jwt.io to decode and inspect access tokens and ID tokens. Verify that scopes, patient context, and token expiration are correct.
Browser Developer Tools: Monitor network requests to track OAuth redirects, FHIR API calls, and error responses. The Authorization Code flow involves multiple redirects that can be difficult to follow without network monitoring.
Common Debugging Issues
“Invalid scope” error during authorization: The requested scope is not supported by the EHR or not approved for your application. Check the SMART configuration endpoint for scopes_supported and verify your registered scopes match.
“Invalid redirect URI” error: The redirect URI in your authorization request does not match exactly what you registered with the EHR. Check for trailing slashes, protocol differences (http vs https), and port numbers.
Empty patient context: The token response does not include a patient parameter. Verify your scope includes launch (for EHR launch) or launch/patient (for standalone launch).
CORS errors on FHIR API calls: The EHR’s FHIR server is blocking browser-to-server requests. Use a backend proxy or confirm that the EHR supports CORS for your application’s origin.
Deployment and EHR Marketplace Listing
Hosting Requirements
- Your SMART app must be hosted on HTTPS (TLS 1.2+)
- The app must be available at a stable URL (the launch URL registered with the EHR)
- For embedded (iframe) apps, ensure your server sends appropriate X-Frame-Options and Content-Security-Policy headers
- High availability is expected — EHR launch failures create clinical workflow disruption
- Target sub-2-second load time for the initial launch
EHR Marketplace Submission
Each EHR has its own marketplace and approval process:
Epic App Orchard/Showroom: Submit through the Epic developer portal. Expect 2–4 months for review. Each Epic customer site must independently activate your app. See our Epic integration guide for detailed steps.
Oracle Health (Cerner Code): Submit through the Oracle Health developer portal. Expect 4–8 weeks for review. Per-site activation required. See our Oracle Health integration guide for details.
Athenahealth Marketplace: Submit through the athenahealth developer portal. Expect 6–12 weeks for review. No per-site activation — approved apps work for all athenahealth customers. See our athenahealth integration guide for the Marketplace process.
Multi-EHR Deployment Architecture
For apps supporting multiple EHR platforms, use a configuration-driven architecture:
- Store EHR-specific configuration (FHIR base URL, client ID, supported scopes) per deployment target
- Use the SMART discovery mechanism to dynamically resolve OAuth endpoints
- Handle EHR-specific FHIR resource variations in a translation layer
- Test against each EHR’s sandbox independently before cross-platform deployment
Common Mistakes and How to Avoid Them
Hardcoding FHIR server URLs. Your app must dynamically discover the FHIR server URL from the iss parameter during launch. Hardcoded URLs break when deploying to different EHR instances or when EHR infrastructure changes.
Ignoring token expiration. Access tokens expire. Implement token refresh logic and handle 401 (Unauthorized) responses gracefully by refreshing the token and retrying the request.
Not handling missing data. FHIR resources have optional fields. A Patient resource may not have a phone number. A Condition may not have an onset date. An Observation may not have a reference range. Your app must handle null/undefined values for every field without crashing.
Requesting too many scopes. Broad scopes (patient/*.read) may be rejected by EHR authorization servers or raise flags during marketplace review. Request only the specific resource types your app needs.
Not supporting both launch types. Building only EHR launch or only standalone launch limits your app’s utility. Support both with shared application logic.
Slow app load time. SMART apps launch in clinical workflows where seconds matter. Optimize your initial load — defer non-critical data fetches, use loading states, and lazy-load components. If your app takes more than 3 seconds to display useful content after launch, clinicians will not use it.
Not testing with real EHR sandboxes. Public FHIR sandboxes behave differently from Epic, Oracle Health, and athenahealth sandboxes. Always test against the specific EHR sandbox before submitting for marketplace review.
Ignoring CDS Hooks response time limits. EHRs time out CDS Hook requests after 5–10 seconds. If your CDS service is slow, the EHR discards your response and the clinician never sees your cards. Pre-compute results and cache aggressively.
Next Steps
SMART on FHIR is the standard for building interoperable healthcare applications. Mastering this framework gives your application access to every major EHR platform in the United States through a single technical architecture.
If you are building a SMART on FHIR application and need guidance on multi-EHR deployment, CDS Hooks integration, or marketplace approval, connect with our interoperability team or explore our healthcare interoperability services.
Related Resources:
- Healthcare Interoperability Services
- Epic EHR Integration Guide
- Cerner/Oracle Health Integration Guide
- Athenahealth API Integration Guide
- HL7 Integration Services
- FHIR Integration Services
This tutorial was developed by the healthcare interoperability team at Taction Software, based on production SMART on FHIR implementations deployed across Epic, Oracle Health, and athenahealth environments for US healthcare organizations and health tech companies.
Frequently Asked Questions
No. A single SMART on FHIR app can work across all three platforms. The SMART launch framework and FHIR R4 API are standardized. You will need separate app registrations with each EHR vendor, and you should test against each vendor’s sandbox, but the core application code is shared.
Yes, but write access is more restricted than read access. You must request write scopes, and the EHR vendor must approve those scopes during the marketplace review process. Common write use cases include creating Observations (patient-reported data, device readings), creating DocumentReferences (clinical documents), and creating ServiceRequests (orders). Each EHR has different write capabilities — check the vendor’s FHIR documentation.
SMART on FHIR’s standard OAuth flow is designed for interactive sessions. For background access, use the Backend Services authorization profile with client credentials. This requires a separate app registration with system-level scopes and is subject to additional approval from EHR vendors.
Any language that can handle HTTP requests and OAuth 2.0. JavaScript (with the fhirclient library) is most common for web-based SMART apps. Python, Java, C#, and Swift all have FHIR client libraries. Mobile SMART apps use Swift (iOS) or Kotlin (Android) with FHIR client libraries.
Your SMART app must implement all standard HIPAA safeguards: encrypt data in transit (HTTPS), minimize local data storage, implement session timeout, audit log all data access, and handle PHI according to your BAA. The SMART framework handles authorization and scoping, but your app is responsible for everything that happens after you receive the data.
FHIR is the data standard — it defines resources, search parameters, and API conventions. SMART on FHIR adds the launch framework (how apps start and receive context from the EHR) and the authorization framework (how apps get permission to access data). You can use FHIR APIs without SMART (direct API calls with API keys or service accounts), but SMART is required for apps that launch from within the EHR and need user-context-aware authorization.
A simple read-only SMART app (display patient data) can be built in 2–4 weeks. A full-featured clinical workflow app with CDS Hooks and write operations typically takes 2–5 months. Add 2–4 months for EHR marketplace review and approval per vendor. Taction Software’s multi-EHR SMART app deployments average 4–7 months from development start to production availability across all three major EHR platforms.