Healthcare ,

How to Build SMART on FHIR Applications That Pass Every EHR Review

From OAuth 2.0 authorization flows and PKCE to patient context, granular scopes, backend services JWT auth, CDS Hooks integration, and App Orchard certification — the complete engineering playbook for building SMART on FHIR apps on Epic, Cerner, and Athenahealth.

How to Build SMART on FHIR Applications That Pass Every EHR Review

  • Last Updated on May 21, 2026
  • 23 min read

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.

170M+

US patients whose data is accessible via SMART on FHIR APIs mandated by ONC HTI-1

3,400+

Apps listed in Epic's App Orchard — the largest SMART on FHIR app marketplace

6 wk

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.

TYPESCRIPT — PKCE EHR LAUNCH IMPLEMENTATIONSTART v2 - EHR-HL7
// 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 CategorySMART v1 (Deprecated)SMART v2 (Required)Grants
Patient resource readpatient/*.readpatient/Patient.read

Read Patient resources for the in-scope patient

Observation readpatient/*.readpatient/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 readsystem/*.read

system/Patient.read
system/Condition.read

Backend Services: cross-patient read without user context

User identityopenid profileopenid fhirUser

ID token + FHIR Practitioner/Patient reference for logged-in user

Offline accessoffline_accessoffline_access

Refresh token for sessions beyond initial access token lifetime

Wildcard Scope (Rejected by EHRs)

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.

Minimal Explicit Scopes (Approved)

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.

TYPESCRIPT — SMART EHR LAUNCH CONTEXTEHR LAUNCH CONTEXT
// 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.

TYPESCRIPT — FHIR CLIENT PATTERN FOR SMART APPSFHIR v1 - JS SDK
// 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.

PYTHON — SMART EHR BACKEND SERVICES JWT FLOWASYMMETRIC - RS384
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.

PYTHON FASTAPI — CDS HOOKS SERVICE IMPLEMENTATIONCDS HOOKS 1.3
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
author-profile

Ubaid Pisuwala

Ubaid Pisuwala is a highly regarded healthtech expert and Co-founder of Peerbits. He possesses extensive experience in entrepreneurship, business strategy formulation, and team management. With a proven track record of establishing strong corporate relationships, Ubaid is a dynamic leader and innovator in the healthtech industry.

Related Post

Award Partner Certification Logo
Award Partner Certification Logo
Award Partner Certification Logo
Award Partner Certification Logo
Award Partner Certification Logo