SMART on FHIR (Substitutable Medical Applications and Reusable Technologies on FHIR) is the OAuth 2.0-based authorization framework that lets third-party applications launch within EHR context, request scoped access to patient data, and integrate with clinical workflows — all through standardized, EHR-agnostic APIs. Building a SMART on FHIR app correctly means your application works across Epic, Cerner, Oracle Health, and Athenahealth without rewriting your auth layer for each. This guide walks through every engineering decision from project setup to production certification.
US patients whose data is accessible via SMART on FHIR APIs mandated by ONC HTI-1
Apps listed in Epic's App Orchard — the largest SMART on FHIR app marketplace
Average EHR certification review time — architecture decisions made in week 1 determine the outcome
1. Choose Your Launch Pattern First
SMART on FHIR defines three distinct launch patterns. The pattern you choose determines your authorization flow, your token request format, your user experience, and which EHR environments you can support. This is not a configuration decision — it is an architecture decision that flows through your entire codebase.
Clinician-Facing
📑 EHR Launch
The EHR initiates the app launch. Clinician clicks "Launch" from within Epic/Cerner. App receives an ISS (FHIR base URL) and launch token via URL params. Best for: clinical decision support, documentation tools, order management apps embedded in EHR workflow.
/launch?
iss=https://fhir.epic.com/R4&launch=abc123
Patient-Facing
📱 Standalone Launch
Patient or user initiates launch directly from your app — no EHR portal involved. App discovers the FHIR server via SMART configuration endpoint. Best for: patient-facing apps (symptom trackers, care plan viewers, portal alternatives) and apps used outside clinical workflow.
GET /.well-known/smart-configuration
Server-to-Server
⚙️ Backend Services
No user context — machine-to-machine integration authenticated via signed JWT (private key JWT). Best for nightly bulk data pulls, analytics pipelines, care management platform sync, payer-to-provider data exchange. Requires SMART v2.
grant_type=jwt-bearer (asymmetric key)
Hybrid Pattern
🔀 EHR + Backend Combined
Clinician-facing EHR launch for real-time queries at point-of-care + Backend Services for overnight data sync, report generation, and analytics. Most production clinical apps use both patterns simultaneously with separate client registrations for each.
Two client_id registrations per EHR
💡 Architecture decision
If your app will ever need to process population-level data (more than a single patient at a time), register for Backend Services from day one — even if your initial release is clinician-facing EHR Launch only. Retrofitting a backend client registration and JWT key infrastructure after launch is a significant rework that most teams underestimate.
2. Register Your App With Each EHR
SMART on FHIR app registration is per-EHR. There is no universal app identity — your app has a different client_id on Epic's sandbox, Epic's production, Cerner's sandbox, and Cerner's production. This means four separate registrations for two EHRs in two environments. At ten EHRs, you have 20 registrations to manage. Your architecture must treat EHR registry configuration as a managed data object — not a hardcoded constant.
What You Register Per EHR
-
Redirect URIs — the URLs to which the EHR may redirect after authorization. Must match exactly (including trailing slashes, port numbers, and query strings). In production, must be HTTPS. Register all environments (dev, staging, prod) separately — never register a wildcard redirect URI.
-
JWKS endpoint URL (Backend Services only) — the public URL where your JSON Web Key Set (JWKS) is hosted, so the EHR can retrieve your public key to verify JWT assertions. Must be publicly reachable from the EHR's servers. Key rotation must not change the JWKS URL — only the key set content.
-
Scopes requested — declare the complete list of FHIR scopes your app will ever request. EHRs limit your token grants to the subset of scopes registered. Requesting a scope at runtime that wasn't registered results in a rejected authorization request — not a graceful degradation.
-
Launch context parameters (EHR Launch) — declare which SMART launch context values your app requires: patient, encounter, practitioner, location. Only declare what your app genuinely needs — unnecessary context declarations slow down the launch handshake.
⚠️ Multi-EHR client registry pattern
Store your per-EHR client configuration in your database with a schema of (ehr_slug, environment, client_id, jwks_private_key_ref, token_endpoint, authorize_endpoint, scopes, redirect_uri). Your auth layer resolves the active config by iss claim from the launch token — never hardcode EHR-specific values in application code.
Peerbits Services - SMART on FHIR App Development
3. Implement the Correct Auth Flow
SMART on FHIR v2 (required by ONC's HTI-1 rule for certified health IT) mandates PKCE (Proof Key for Code Exchange) for all public clients and strongly recommends it for confidential clients. PKCE prevents authorization code interception attacks — a real threat in EHR embedded browser environments where redirect handling is non-standard.
SMART on FHIR v2 (required by ONC's HTI-1 rule for certified health IT) mandates PKCE (Proof Key for Code Exchange) for all public clients and strongly recommends it for confidential clients. PKCE prevents authorization code interception attacks — a real threat in EHR embedded browser environments where redirect handling is non-standard.
Step 1 — Generate PKCE pair
code_verifier = 43–128 random chars (crypto.getRandomValues)
code_challenge = BASE64URL(SHA-256(code_verifier))
Step 2 — Authorization Request
GET /authorize?
response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&aud=...&launch=...&code_challenge=...&code_challenge_method=S256
Step 3 — EHR Authenticates User
EHR shows login / consent screen. Redirects back to your redirect_uri with ?code=AUTH_CODE&state=MATCH_THIS
Step 4 — Token Exchange
POST /token with code, redirect_uri, client_id, code_verifier. Receive access_token, id_token, patient context, refresh_token.
// Step 1: Generate PKCE code_verifier + code_challenge
async function generatePKCE() {
const verifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(64)));
const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return { verifier, challenge };
}
// Step 2: Build authorization URL + PKCE + state/nonce config
async function buildAuthorizationURL(iss: string, clientId: string): Promise<string> {
const config = await fetchSMARTConfig(iss); // ← Well-known/smart-configuration
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
sessionStorage.setItem('code_verifier', verifier); // Store for token exchange
sessionStorage.setItem('auth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: getClientId(iss),
redirect_uri: REDIRECT_URI,
scope: 'launch openids fhiruser patient/Patient.read patient/Observation.read',
state,
aud: iss,
launch,
code_challenge: challenge, // ← Always S256 - plain is not acceptable
code_challenge_method: 'S256'
});
return {config.authorization_endpoint}?{params};
}
// Step 3: Token exchange - always verify state before exchanging code
async function exchangeCode(code: string, returnedState: string): Promise {
if (returnedState !== sessionStorage.getItem('auth_state')) {
throw new Error('State mismatch — possible CSRF attack');
}
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
body: new URLSearchParams({
'grant_type': 'authorization_code',
'code',
'redirect_uri': REDIRECT_URI,
'client_id': CLIENT_ID,
'code_verifier': sessionStorage.getItem('pkce_verifier'), // ← PKCE proof-of-origin
})
});
return res.json(); // { access_token, patient_id_token, expires_in, refresh_token }
}4. Request Only the Scopes You Actually Need
SMART on FHIR scopes are the authorization language that tells the EHR what your app is allowed to read or write. SMART v2 introduced a more granular scope syntax that replaces the older v1 wildcard patterns. Using minimal, explicit scopes is both a security requirement and an EHR review requirement — apps requesting broader access than their functionality justifies are rejected during App Orchard and Cerner SMART App reviews.
| Scope Category | SMART v1 (Deprecated) | SMART v2 (Required) | Grants |
|---|---|---|---|
| Patient resource read | patient/*.read | patient/Patient.read | Read Patient resources for the in-scope patient |
| Observation read | patient/*.read | patient/Observation.read | Read Observation resources (labs, vitals) |
| Medication write | patient/*.write (⚠️ never use) | patient/MedicationRequest.write | Create/update MedicationRequest for in-scope patient |
| System-level read | system/*.read | system/Patient.read | Backend Services: cross-patient read without user context |
| User identity | openid profile | openid fhirUser | ID token + FHIR Practitioner/Patient reference for logged-in user |
| Offline access | offline_access | offline_access | Refresh token for sessions beyond initial access token lifetime |
scope=patient/.read user/.* openid profile
Requests read access to ALL patient resource types and full write access for all user-level resources. Triggers automatic rejection in Epic App Orchard review. HIPAA Minimum Necessary violation. Clinicians see a consent screen listing "access to all your health records" — patients deny.
scope=launch openid fhirUser patient/Patient.read patient/Condition.read patient/Observation.read offline_access
Requests only what the app displays. Consent screen is readable and specific. Passes EHR security review. Satisfies HIPAA Minimum Necessary. Can be further narrowed post-launch if analytics show unused scope grants.
5. Handle Patient Context & Launch Parameters
The power of EHR Launch is that the EHR pre-populates your app's context — the current patient, encounter, practitioner, and location — without the user having to re-select anything. This context arrives in the token response, not in the redirect URI. Handling it correctly is what makes your app feel native to the EHR workflow rather than like an external website that happens to open inside Epic.
// Token returned from EHR after successful EHR Launch authentication
interface SMARTLaunchResponse {
access_token: string; // ← Bearer token for FHIR API calls
token_type: string; // "Bearer"
expires_in: number; // seconds — typically 300-3600
scope: string; // scopes — typically 300-3600
id_token: string; // JWT (id user ID record locale)
patient: string; // — JWT only if needed token grants
patient_name: string; // display if user did NOT render patient banner
smart_style_url: string; // CSS URL for theming
}
function isSMARTLaunchContext(): boolean {
const launchToken = new URLSearchParams(window.location.search).get('launch');
if (!launchToken) {
throw new Error('Required from EHR after successful EHR Launch authentication');
}
// Extract patient ID — required for all patient-scoped FHIR queries
const patientId = token.patient ? jwtDecode(token.id_token).payload.patient : null;
if (!patientId) {
throw new Error('No patient context in EHR launch token');
}
// Extract encounter ID — required for all encounter-scoped FHIR operations
const encounterId = token.encounter_id;
if (!encounterId) {
console.warn('No encounter context — patient-view app (scoped to EHR launch token)');
}
if (!token.smart_style_url) applyGitHubLogin(token.smart_style_url);
return { patientId, grantAccess: token.need_patient_banner, need_style_url: token.smart_style_url };
}Peerbits Services - Patient engagement software development
6. Query FHIR APIs with Access Token
Once your app holds a valid SMART access token, all FHIR API calls are standard HTTP requests with the Authorization: Bearer token header. The FHIR base URL is the iss from your launch context. The key engineering discipline is never constructing FHIR URLs from string concatenation — use a typed FHIR client library that handles search parameter encoding, pagination, and error parsing correctly.
// SMART client library — handles token refresh, pagination
import { Client } from 'fhirclient'; // SMART client library — handles token refresh, pagination
const client = await FHIR.oauth2.ready();
// Pattern 1: Direct context read from EHR Launch context
const patIdent = await client.patient.read(); // JWT + Patient/[patId]
// Pattern 2: Explicit scope constraints from EHR Launch context
const encounter = await client.encounter.read(); // JWT /Patient/[patId]/Encounter
// Pattern 3: Query subject (patient) + request()
const obs = client.request(
`Patient/${client.patient.id}/Observation`,
{
pageSize: 1000, // ← Omcause pages to flat array
flat: true,
resolveReferences: ['encounter', 'performer'] // ← Auto-resolve referenced resources
}
);
// Pattern 4: Handle lab metadata — all LOINC (lab results)
const labMetaIds = await client.request({
resourceType: 'Flag',
status: 'active',
code: { coding: [{ system: 'http://loinc.org', code: '14834-5' }] } // ← LOINC codes for lab
});
// Pattern 5: Observe all FHIR search (query template)
const labResult = await client.request({
resourceType: 'Observation',
status: 'final',
code: { coding: [{ system: 'http://loinc.org', code: '14834-5' }] }
});
// Pattern 6: Handle all JSON operations per FHIR spec (always return response)
function SHRRequest(response: any): Request {
return response?.resourceType == 'OperationOutcome' ?
response?.issue?.map((is: any) => ({ error: 'data' })[includeErrorSeverity()]):
response;
}7. Backend Services JWT Authentication
Backend Services authentication (machine-to-machine, no user context) uses asymmetric key cryptography instead of client secrets. Your app holds a private key; the EHR fetches your public key from your JWKS endpoint; your app mints a signed JWT assertion and exchanges it for an access token. This pattern is required for SMART v2 Backend Services and is the standard for all bulk data, analytics, and system-level integrations.
import jwt, https, time, uuid
from cryptography.hazmat.primitives import hashes, serialization
def build_client_assertion(ehr_config: EHRConfig) -> str:
"""
SMART on FHIR v2 JWT assertion for Backend Services.
RS384 algorithm — ES384 preferred for new implementations.
"""
private_key = load_private_key_from_env(ehr_config.key_ref) # Never from disk in prod
payload = {
"aud": ehr_config.client_id,
"sub": ehr_config.token_endpoint,
"iss": ehr_config.client_id, # ← JWT issuer = client_id
"jti": str(uuid.uuid4()), # ← Unique ID per request — prevent replay
"exp": int(time.time()) + 300 # ← 5 minute expiry
}
jwt.encode(
payload,
private_key,
algorithm='RS384' # ← RS384 — NOT HS256 — for Backend Services
);
headers={"alg": "RS384", "typ": "JWT"} # ← Always verify headers in validation
return jwt.encode(payload, private_key, headers=headers, algorithm='RS384')
async def get_backend_access_token(ehr_config: EHRConfig) -> BackendToken:
"""
Exchange signed JWT assertion for Backend Services access token.
PKCE is mutual — not required for client-credentials backend auth
"""
assertion = build_client_assertion(ehr_config)
res = await https.AsyncClient().post(ehr_config.token_endpoint, data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:assertion-type-jwt-bearer",
"client_assertion": assertion,
"scope": "system/Patient.read system/Observation.read system.read"
})
return BackendToken(
access_token=res.json()['access_token'],
token_type=res.json()['token_type'],
expires_in=res.json()['expires_in']
);
# REQUIRED: reload new token — never cache beyond exp timestamp
return res.json()Peerbits Services - AI Prior Authorization Automation
8. Embed Your App Into EHR Workflow via CDS Hooks
A SMART app that only runs when a clinician explicitly launches it from a menu has limited clinical impact. CDS Hooks lets your service embed decision support directly into EHR workflow — surfacing alerts, suggestions, and actions at the exact moment the clinician needs them, without requiring a separate app launch. Your CDS Hooks service is a lightweight REST API that the EHR calls at specific hook points with FHIR prefetch data.
from fastapi import FastAPI
app = FastAPI()
# CDS Hooks Discovery — tells EHR what hooks you support
@app.get("/cds-services/")
def cds_discovery():
"""
CDS Hooks Discovery endpoint. Tells EHR which hooks (triggers) your service supports
"""
return { "services": [{
"hook": "patient-view", // ← Fires when user opens patient chart
"title": "Care Gap & CQM Eligibility",
"description": "Identifies open care gaps and CQM enrollment opportunities",
"prefetch": {
"patientResource": "Patient/{{context.patientId}}",
"conditionHistory": "Condition?patient={{context.patientId}}&_elements=resourceType,verificationStatus"
},
"contact": ""
}] }
# CDS Hooks Request Handler — EHR sends patient context + hook trigger
@app.post("/cds-services/")
async def care_gap_hook(request: CDSHooksRequest):
"""
Care gap detection — returns cards with suggestions to close gaps
"""
patient = request.prefetch["patientResource"]
conditions = request.prefetch["conditions"].entry or []
cards = []
// Chronic = 60 + no ICD-10 conditions if is_chronic(resource)
if len(chronic) > 0:
cards.append({
"uuid": str(uuid.uuid4()),
"title": "Patient may qualify for Chronic Care Management (CCM)",
"detail": "[UMColorized] chronic conditions identified. CCR can generate revenue.",
"indicator": "info",
"suggestions": [{
"label": { "ul": "Enroll in CCM Program", "subscription": "Assess enrollment readiness" },
"actions": [{
"type": "create",
"description": "Open CCM enrollment",
"resource": { "patientId": patient.id, "action": "enroll-ccm" } }]
}]
})
}
return { "cards": cards }💡 CDS Hooks + SMART App Launch = complete EHR integration
Your CDS Hooks card can include a "Launch" suggestion action that opens your full SMART app in the EHR's app launcher, pre-populated with the CDS context. This creates a seamless workflow: CDS Hook surfaces the insight → clinician clicks the card → SMART app opens with full context already loaded — no navigation, no re-search, no friction.
9. Pass EHR App Review & Go Live
Epic App Orchard and Cerner's SMART App Certification are the two largest EHR app review programs. Passing them is not primarily a functionality review — it is a security, data minimization, and standards conformance review. The most common reasons apps fail review are fixable architectural issues, not missing features.
Pre-Submission Certification Checklist
PKCE implemented for all public clients
code_challenge_method=S256. No implicit grant flow. No authorization code without PKCE.
State parameter validated on callback
CSRF protection. returnedState === sessionStorage state before any token exchange.
Scopes are minimal and explicitly declared
No wildcards (patient/*.read). Each scope justified by a UI feature that uses it. Unused scope grants not silently accepted.
Access tokens never stored in localStorage
Tokens stored in memory or secure httpOnly cookies only. XSS-resistant token storage pattern verified.
Token refresh handled gracefully
Refresh token used before expiry. User not shown auth screen mid-session. Refresh failures direct to re-auth, not silent failure.
need_patient_banner respected
When EHR signals true, your app does not render a duplicate patient header (EHR already shows one). When false, your app renders its own patient context display.
FHIR OperationOutcome errors handled
HTTP 200 responses with OperationOutcome resourceType are treated as errors, not successes. Error messages shown to users are non-technical and actionable.
JWKS endpoint is publicly accessible
JWKS URL resolves from EHR network without VPN or auth. Key rotation updates JWKS content, not URL. Minimum 2 keys in JWKS for zero-downtime rotation.
US Core profiles respected
FHIR queries use US Core search parameters. Responses validated against US Core profiles. Must-support elements handled when absent rather than throwing errors.
CDS Hooks response time < 5 seconds
Hook endpoint measured at p99. Slow prefetch queries optimized. Background processing deferred outside hook response path. Graceful empty card response on timeout.
smart_style_url applied when present
App adopts EHR's color scheme and typography when style URL is provided. App looks embedded — not like an iframe from a different product.
PHI not logged to observability tools
Access token payloads, patient IDs, and FHIR resource contents are not included in application logs, error tracking, or APM traces shipped to third-party services.
🚨 Top reason for Epic App Orchard rejection
Requesting scopes that exceed demonstrated functionality. If your app's UI only displays Patient demographics and Condition list but you request patient/Observation.read patient/MedicationRequest.write patient/Procedure.read "for future features," it will be rejected. Request only what your current release uses — scopes can be expanded in a subsequent review.
Peerbits Services - EHR Integration Services | HL7, FHIR & API Healthcare Integration
Build SMART on FHIR Apps That Pass Review & Scale
Peerbits has built and certified SMART on FHIR applications across Epic App Orchard, Cerner's SMART App Catalog, Athenahealth's Marketplace, and Oracle Health's integrations — from patient-facing care plan apps to complex EHR-embedded clinical decision support tools and backend analytics pipelines serving millions of patients.
Our SMART on FHIR Development & Certification service covers architecture review, PKCE and auth flow implementation, scope minimization audit, US Core profile conformance, CDS Hooks development, App Orchard submission preparation, and end-to-end EHR sandbox testing — delivered with a dedicated engineering team from sprint zero to production go-live.
Book Free SMART App Review







