SMART on FHIR Tutorial: Build a Compliant Healthcare App from Scratch (2026)
Step-by-step SMART on FHIR tutorial — implement OAuth 2.0 standalone launch, EHR launch, and backend services authentication with real code examples in Python and .NET. From SMART discovery through token exchange to FHIR API calls.
SMART on FHIR is the security standard that makes the US healthcare interoperability ecosystem work. It is the OAuth 2.0 profile that tells your application how to authenticate with an EHR, what data it can access, and how to represent the clinical context (which patient, which encounter, which clinician) that makes healthcare API calls meaningful.
Understanding SMART on FHIR is not optional for anyone building healthcare applications in the US — ONC mandates it for patient access, EHR vendors require it for all modern integrations, and the Da Vinci payer-provider exchange IGs depend on it. This tutorial walks through every SMART on FHIR pattern with real code.
What SMART on FHIR Is
SMART on FHIR is a specification published by the SMART Health IT project and standardised through HL7. It defines:
- SMART App Launch Framework — how an application launches from within an EHR, or independently, and authenticates using OAuth 2.0
- SMART Scopes — a structured vocabulary of OAuth 2.0 scopes for requesting access to specific FHIR resource types and operations
- Launch context — additional context (patient ID, encounter ID, practitioner ID) that the EHR passes to the application to orient it to the correct clinical context
SMART on FHIR builds on standard OAuth 2.0 and OpenID Connect, so the concepts are familiar to any developer who has implemented OAuth before. The healthcare-specific additions are the scope vocabulary and the launch context mechanism.
The Three SMART Launch Patterns
Pattern 1: Standalone Launch
The user is not inside the EHR when they start. They open your application directly (via URL, mobile app, or desktop app) and authenticate using their EHR credentials. This is the pattern for patient-facing applications, consumer health apps, and applications that operate outside the clinical workflow.
Patient accesses their data from their phone → Your app → FHIR API.
Pattern 2: EHR Launch
The clinician is inside the EHR and launches your application from within the EHR's app launcher or embedded application panel. The EHR provides launch context — the patient whose chart is open, the current encounter, the authenticated clinician's identity — eliminating the need for the user to re-authenticate or re-select context.
Clinician in Epic → Launches your CDS app → Your app gets patient/encounter context automatically.
Pattern 3: Backend Services (SMART v2)
Server-to-server communication with no user involved. A data pipeline, analytics job, or integration engine authenticates as an application (not as a user) using asymmetric key cryptography (JWT signed with your private key). Used for population health, bulk data export, and system integrations.
Your pipeline → Authenticates with private key JWT → Gets access token → Bulk FHIR queries.
SMART Discovery: Finding Endpoints
Before implementing any SMART flow, your application must discover the EHR's authorisation endpoints. This is done via SMART's well-known configuration endpoint.
import httpx
async def discover_smart_endpoints(fhir_base_url: str) -> dict:
"""
Fetch SMART configuration from the EHR's well-known endpoint.
Every SMART-compliant FHIR server exposes this endpoint.
"""
discovery_url = f"{fhir_base_url.rstrip('/')}/.well-known/smart-configuration"
response = await httpx.get(discovery_url)
response.raise_for_status()
config = response.json()
return {
"authorization_endpoint": config["authorization_endpoint"],
"token_endpoint": config["token_endpoint"],
"scopes_supported": config.get("scopes_supported", []),
"response_types_supported": config.get("response_types_supported", []),
"capabilities": config.get("capabilities", [])
}
# Example usage
config = await discover_smart_endpoints("https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4")A typical SMART configuration response:
{
"issuer": "https://fhir.epic.com",
"authorization_endpoint": "https://fhir.epic.com/oauth2/authorize",
"token_endpoint": "https://fhir.epic.com/oauth2/token",
"token_endpoint_auth_methods_supported": ["client_secret_basic", "private_key_jwt"],
"scopes_supported": ["openid", "fhirUser", "launch", "launch/patient", "patient/*.*", "user/*.*", "offline_access"],
"response_types_supported": ["code"],
"capabilities": ["launch-ehr", "launch-standalone", "client-public", "client-confidential-symmetric", "sso-openid-connect", "context-passthrough-banner", "context-ehr-patient", "permission-patient", "permission-user"],
"code_challenge_methods_supported": ["S256"]
}Capabilities tell you which SMART features the server supports. Check for launch-ehr before attempting EHR launch, launch-standalone for standalone launch, and client-confidential-asymmetric for backend services.
Implementing Standalone Launch
Standalone launch follows standard OAuth 2.0 PKCE authorization code flow with SMART extensions.
import os
import hashlib
import base64
import secrets
import urllib.parse
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
app = FastAPI()
SMART_CONFIG = {
"client_id": os.environ["EPIC_CLIENT_ID"],
"client_secret": os.environ.get("EPIC_CLIENT_SECRET"), # None for public clients
"redirect_uri": "https://yourapp.com/callback",
"fhir_base_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
"scopes": ["openid", "fhirUser", "patient/Patient.read", "patient/Observation.read",
"patient/MedicationRequest.read", "patient/Condition.read", "offline_access"]
}
# In-memory state store (use Redis in production)
pending_auth = {}
@app.get("/launch")
async def standalone_launch():
"""Initiate standalone SMART launch."""
# Generate PKCE code verifier and challenge
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Store state and code_verifier for callback verification
pending_auth[state] = {
"code_verifier": code_verifier,
}
# Build authorisation URL
config = await discover_smart_endpoints(SMART_CONFIG["fhir_base_url"])
params = {
"response_type": "code",
"client_id": SMART_CONFIG["client_id"],
"redirect_uri": SMART_CONFIG["redirect_uri"],
"scope": " ".join(SMART_CONFIG["scopes"]),
"state": state,
"aud": SMART_CONFIG["fhir_base_url"], # Required by SMART
"code_challenge": code_challenge,
"code_challenge_method": "S256"
}
auth_url = config["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
return RedirectResponse(auth_url)
@app.get("/callback")
async def auth_callback(request: Request):
"""Handle OAuth 2.0 callback after user authenticates with EHR."""
code = request.query_params.get("code")
state = request.query_params.get("state")
error = request.query_params.get("error")
if error:
return {"error": error, "description": request.query_params.get("error_description")}
if state not in pending_auth:
return {"error": "Invalid state parameter"}
code_verifier = pending_auth.pop(state)["code_verifier"]
# Exchange code for tokens
config = await discover_smart_endpoints(SMART_CONFIG["fhir_base_url"])
token_data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": SMART_CONFIG["redirect_uri"],
"client_id": SMART_CONFIG["client_id"],
"code_verifier": code_verifier
}
auth = None
if SMART_CONFIG["client_secret"]:
auth = (SMART_CONFIG["client_id"], SMART_CONFIG["client_secret"])
async with httpx.AsyncClient() as client:
response = await client.post(
config["token_endpoint"],
data=token_data,
auth=auth
)
token_response = response.json()
# Extract SMART launch context from token response
access_token = token_response["access_token"]
patient_id = token_response.get("patient") # FHIR Patient ID
# Store tokens securely (use encrypted session/database in production)
# ...
return {
"status": "authenticated",
"patient_id": patient_id,
"scope": token_response.get("scope")
}Implementing EHR Launch
EHR launch adds a launch parameter to the authorisation request — a short-lived token that encodes the EHR's clinical context. Epic (or any EHR) calls your launch URL with this token.
@app.get("/ehr-launch")
async def ehr_launch(request: Request):
"""
Entry point when Epic launches your app from the EHR.
Epic sends: ?launch=LAUNCH_TOKEN&iss=EPIC_FHIR_BASE_URL
"""
launch_token = request.query_params.get("launch")
iss = request.query_params.get("iss") # EHR's FHIR base URL
if not launch_token or not iss:
return {"error": "Missing launch or iss parameters"}
# Discover SMART endpoints from the issuing EHR
config = await discover_smart_endpoints(iss)
# Generate state and PKCE
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(32)
pending_auth[state] = {
"code_verifier": code_verifier,
"iss": iss
}
# EHR launch scopes include 'launch' and optionally launch/patient
scopes = ["openid", "fhirUser", "launch",
"patient/Patient.read", "user/Observation.read",
"user/MedicationRequest.read", "user/Condition.read"]
params = {
"response_type": "code",
"client_id": SMART_CONFIG["client_id"],
"redirect_uri": SMART_CONFIG["redirect_uri"],
"scope": " ".join(scopes),
"state": state,
"aud": iss,
"launch": launch_token, # Pass the EHR-provided launch token
"code_challenge": code_challenge,
"code_challenge_method": "S256"
}
auth_url = config["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
return RedirectResponse(auth_url)After the token exchange for EHR launch, the token response includes richer context:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch patient/Patient.read user/Observation.read",
"patient": "eovIfYjifN7cWQTGs7GEkQ3", // In-context patient FHIR ID
"encounter": "eKPJ7jfGhPMPLovgpqPvwbg3", // In-context encounter FHIR ID
"location": "e3m5UqtlJjN3dkT.KQtHqEQ3", // In-context location FHIR ID
"smart_style_url": "https://...", // EHR's style for embedded apps
"need_patient_banner": false // Whether to show patient name
}SMART Backend Services Authentication (.NET Example)
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;
public class SmartBackendServicesClient
{
private readonly string _clientId;
private readonly RSA _privateKey;
private readonly HttpClient _httpClient;
public SmartBackendServicesClient(string clientId, string privateKeyPem, HttpClient httpClient)
{
_clientId = clientId;
_privateKey = RSA.Create();
_privateKey.ImportFromPem(privateKeyPem);
_httpClient = httpClient;
}
private string CreateClientAssertion(string tokenEndpoint)
{
var signingCredentials = new SigningCredentials(
new RsaSecurityKey(_privateKey),
SecurityAlgorithms.RsaSha384
);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Iss, _clientId),
new Claim(JwtRegisteredClaimNames.Sub, _clientId),
new Claim(JwtRegisteredClaimNames.Aud, tokenEndpoint),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GetAccessTokenAsync(string tokenEndpoint, IEnumerable<string> scopes)
{
var assertion = CreateClientAssertion(tokenEndpoint);
var formData = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
["client_assertion"] = assertion,
["scope"] = string.Join(" ", scopes)
};
var response = await _httpClient.PostAsync(
tokenEndpoint,
new FormUrlEncodedContent(formData)
);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var tokenResponse = JsonSerializer.Deserialize<JsonElement>(json);
return tokenResponse.GetProperty("access_token").GetString()!;
}
}SMART Scopes Reference
SMART scopes control what your application can access. The format is {context}/{resourceType}.{operation}:
| Scope | Access |
|-------|--------|
| patient/Patient.read | Read the in-context patient's Patient resource |
| patient/Observation.read | Read observations for the in-context patient |
| user/Patient.read | Read any Patient the authenticated user can access |
| system/Patient.read | System-level read on all Patient resources (backend) |
| patient/*.read | Read all resource types for in-context patient |
| user/*.read | Read all resource types for any patient the user can access |
| system/*.read | Read all resource types (backend services) |
| openid | Include OpenID Connect ID token in response |
| fhirUser | Include FHIR user resource URL in token response |
| launch | Accept EHR-provided launch context |
| offline_access | Receive refresh tokens for long-lived access |
Principle of least privilege: Request only the scopes you genuinely need. EHR App Store reviews (Epic App Orchard, Athena Marketplace) are sensitive to over-broad scope requests. Broad scopes like patient/*.read will be questioned; precise scopes like patient/Observation.read patient/Patient.read will pass faster.
Token Management in Production
from datetime import datetime, timedelta
from dataclasses import dataclass, field
import asyncio
@dataclass
class TokenStore:
access_token: str
expires_at: datetime
refresh_token: str | None = None
patient_id: str | None = None
def is_expired(self, buffer_seconds: int = 60) -> bool:
return datetime.utcnow() >= (self.expires_at - timedelta(seconds=buffer_seconds))
class SmartTokenManager:
def __init__(self):
self._tokens: dict[str, TokenStore] = {}
self._lock = asyncio.Lock()
async def get_valid_token(self, session_id: str, token_endpoint: str) -> str:
async with self._lock:
store = self._tokens.get(session_id)
if store and not store.is_expired():
return store.access_token
if store and store.refresh_token:
# Refresh the access token
new_store = await self._refresh_token(store.refresh_token, token_endpoint)
self._tokens[session_id] = new_store
return new_store.access_token
raise TokenExpiredError("Session expired — user must re-authenticate")
async def _refresh_token(self, refresh_token: str, token_endpoint: str) -> TokenStore:
async with httpx.AsyncClient() as client:
response = await client.post(token_endpoint, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token
})
response.raise_for_status()
data = response.json()
return TokenStore(
access_token=data["access_token"],
expires_at=datetime.utcnow() + timedelta(seconds=data["expires_in"]),
refresh_token=data.get("refresh_token", refresh_token)
)Security Considerations
Always use PKCE. PKCE (Proof Key for Code Exchange) prevents authorisation code interception attacks. SMART on FHIR v2 requires PKCE for all public clients and strongly recommends it for confidential clients. There is no reason to omit it.
Validate the iss parameter. In EHR launch, the EHR sends an iss parameter identifying itself. Validate this against a whitelist of known EHR FHIR base URLs — do not accept arbitrary iss values.
Store tokens securely. Access tokens and refresh tokens grant access to PHI. Store them encrypted in your session store (Redis with encryption, encrypted database columns). Never store them in browser localStorage.
Validate the aud claim. Your FHIR API calls should use the token only against the FHIR server that issued it (identified by the aud parameter in your authorisation request). Do not use Epic tokens against Athena or vice versa.
Implement token expiry handling before expiry. Refresh tokens proactively — when the access token is within 60 seconds of expiry — rather than handling 401 Unauthorized reactively. Healthcare workflows cannot tolerate unexpected authentication failures mid-task.
Conclusion
SMART on FHIR is the essential security layer for every FHIR-based healthcare application. Once you understand the three launch patterns — standalone, EHR launch, and backend services — and the scope vocabulary, the implementation is straightforward using standard OAuth 2.0 tooling.
The complexity of SMART integrations is mostly at the integration boundary with specific EHR vendors — Epic's App Orchard process, Athena's Marketplace review, the specifics of each vendor's scope support. That is where experience with specific EHR integrations adds the most value.
Muhammad Moid Shams is a Lead Software Engineer specialising in FHIR R4 integration, SMART on FHIR, and healthcare interoperability.