Documentation/api/SMART_ON_FHIR.md
Complete guide to integrating SMART on FHIR applications with OpenEMR.
SMART on FHIR (Substitutable Medical Applications, Reusable Technologies on Fast Healthcare Interoperability Resources) is a specification for integrating third-party applications with electronic health record systems.
SMART on FHIR enables:
OpenEMR fully supports:
| Standard | Version | Status |
|---|---|---|
| SMART on FHIR | v2.2.0 | ✅ Full Support |
| SMART App Launch | 1.1.0 | ✅ Compliant |
| OAuth 2.0 | RFC 6749 | ✅ Implemented |
| OpenID Connect | 1.0 | ✅ Supported |
| PKCE | RFC 7636 | ✅ Required for public apps |
OpenEMR implements SMART on FHIR v2.2.0 with the following enhancements:
Fine-grained permissions with .cruds syntax:
patient/Observation.rs # Read and search only
user/Patient.crus # Create, read, update, search
See Authorization Guide for details.
More secure authorization requests via POST:
POST /oauth2/default/authorize
Content-Type: application/x-www-form-urlencoded
response_type=code&client_id=...
See Authentication Guide for details.
JWKS-based authentication for confidential clients:
{
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://app.example.com/.well-known/jwks.json"
}
See Authentication Guide for details.
Discovery endpoint for SMART capabilities:
GET /fhir/.well-known/smart-configuration
See SMART Configuration section below.
Validate token status:
POST /oauth2/default/introspect
See Authentication Guide for details.
SMART apps must be registered before use. OpenEMR provides two registration methods.
Recommended for SMART apps - User-friendly registration interface.
https://your-openemr.example.com/interface/smart/register-app.php
Fill in app details:
Submit registration
Receive credentials:
Enable the app (see Enabling Apps)
| Field | Required | Description | Example |
|---|---|---|---|
| App Name | Yes | Display name | "Cardiac Risk Calculator" |
| Launch URL | For EHR launch | EHR launch endpoint | https://app.example.com/launch |
| Redirect URI | Yes | OAuth callback(s) | https://app.example.com/callback |
| App Type | Yes | Confidential or Public | Confidential |
| Client Authentication | Conditional | For confidential apps | client_secret_post |
| JWKS URI | For asymmetric auth | Public key location | https://app.example.com/jwks |
| Scopes | Yes | Required permissions | patient/Patient.rs |
| Logo URL | No | App icon | https://app.example.com/logo.png |
| Contacts | No | Admin emails | [email protected] |
For programmatic registration - See Authentication Guide.
Example:
curl -X POST https://localhost:9300/oauth2/default/registration \
-H 'Content-Type: application/json' \
--data '{
"application_type": "private",
"client_name": "My SMART App",
"redirect_uris": ["https://app.example.com/callback"],
"launch_uris": ["https://app.example.com/launch"],
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid fhirUser launch launch/patient patient/Patient.rs patient/Observation.rs",
"contacts": ["[email protected]"]
}'
Response includes:
client_idclient_secret (confidential apps)After registration, apps require approval based on configuration.
Administration → Config → Connectors → OAuth2 → App Manual Approval
Options:
Automatic Approval (Default)
Manual Approval
Auto-approved if:
patient/* scopes ONLYuser/* or system/* scopesWhy auto-approve?
Require manual approval:
user/* scopessystem/* scopesAdministrator can:
SMART supports two launch patterns: Standalone and EHR Launch.
User-initiated - App launched directly by user (outside EHR).
sequenceDiagram
participant User
participant App as SMART App
participant OpenEMR
User->>App: 1. Launch app
App->>OpenEMR: 2. Authorization request
OpenEMR->>User: 3. Login prompt
User->>OpenEMR: 4. Enter credentials
OpenEMR->>User: 5. Consent screen
User->>OpenEMR: 6. Approve access
OpenEMR->>App: 7. Authorization code
App->>OpenEMR: 8. Exchange for token
OpenEMR->>App: 9. Access token + context
App->>User: 10. Display data
Standalone launch does NOT use launch scope (it may choose to use the launch/patient scope):
Patient standalone:
openid
offline_access
patient/Patient.rs
patient/Observation.rs
Provider standalone:
openid
fhirUser
offline_access
user/Patient.rs
user/Observation.rs
Step 1: App redirects to authorization endpoint
const authUrl = new URL('https://localhost:9300/oauth2/default/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid offline_access patient/Patient.rs patient/Observation.rs');
authUrl.searchParams.set('state', generateRandomState());
authUrl.searchParams.set('aud', 'https://localhost:9300/apis/default/fhir');
// For public apps, add PKCE
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Step 2: Handle callback
After user approval, OpenEMR redirects to redirect_uri:
https://app.example.com/callback?code=AUTHORIZATION_CODE&state=STATE
Step 3: Exchange code for token
See Authentication Guide for token exchange.
Step 4: Use access token
const response = await fetch('https://localhost:9300/apis/default/fhir/Patient/123', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/fhir+json'
}
});
EHR-initiated - App launched from within OpenEMR with pre-established context.
sequenceDiagram
participant User as Clinician
participant OpenEMR
participant App as SMART App
User->>OpenEMR: 1. View patient chart
User->>OpenEMR: 2. Click "Launch App"
OpenEMR->>App: 3. Redirect to launch URL (iss + launch)
App->>OpenEMR: 4. Authorization request (with launch)
OpenEMR->>User: 5. Consent screen (if needed)
User->>OpenEMR: 6. Approve
OpenEMR->>App: 7. Authorization code
App->>OpenEMR: 8. Exchange for token
OpenEMR->>App: 9. Token + patient + encounter
App->>User: 10. Display patient data
EHR launch REQUIRES launch scope:
Minimal EHR launch:
openid
fhirUser
launch
user/Patient.rs
user/Observation.rs
With patient context:
openid
fhirUser
launch
launch/patient
user/Patient.rs
user/Observation.rs
Step 1: OpenEMR redirects to launch URL
OpenEMR initiates launch with:
https://app.example.com/launch?
iss=https://localhost:9300/apis/default/fhir&
launch=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Parameters:
iss - FHIR base URL (issuer)launch - Opaque launch token (single-use)Step 2: App initiates authorization
App must include the launch parameter:
// Extract parameters from launch URL
const params = new URLSearchParams(window.location.search);
const iss = params.get('iss');
const launchToken = params.get('launch');
// Store launch token
sessionStorage.setItem('launch_token', launchToken);
// Build authorization URL
const authUrl = new URL('https://localhost:9300/oauth2/default/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid fhirUser launch launch/patient user/Patient.rs user/Observation.rs');
authUrl.searchParams.set('state', generateRandomState());
authUrl.searchParams.set('aud', iss);
authUrl.searchParams.set('launch', launchToken); // CRITICAL
// PKCE for public apps
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Step 3: Handle callback
Same as standalone launch - receive authorization code.
Step 4: Exchange for token with context
Exchange code as normal. Response includes context:
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid fhirUser launch launch/patient user/Patient.rs",
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
"patient": "123",
"encounter": "456",
"fhirUser": "Practitioner/789"
}
Step 5: Use context
const tokenResponse = await exchangeCodeForToken(code);
// Extract context
const patientId = tokenResponse.patient; // "123"
const encounterId = tokenResponse.encounter; // "456"
const fhirUser = tokenResponse.fhirUser; // "Practitioner/789"
// Fetch patient data
const patient = await fetch(
`https://localhost:9300/apis/default/fhir/Patient/${patientId}`,
{ headers: { 'Authorization': `Bearer ${tokenResponse.access_token}` }}
);
// Fetch encounter data (if provided)
if (encounterId) {
const encounter = await fetch(
`https://localhost:9300/apis/default/fhir/Encounter/${encounterId}`,
{ headers: { 'Authorization': `Bearer ${tokenResponse.access_token}` }}
);
}
Step 1: Register app (see App Registration)
Step 2: Enable app
Step 3: App appears in Patient Summary
| Factor | Standalone | EHR Launch |
|---|---|---|
| Initiated by | User/Patient | EHR/Clinician |
| Context | User provides | Pre-established |
| User Type | Patients, Providers | Providers |
| Workflow | Independent | Integrated |
| Setup | Simpler | More complex |
| Use Cases | Patient apps, portals | Clinical tools, CDS |
Can support both:
launch parameterSMART apps can receive contextual information about the launch environment.
Scope: launch/patient
Provides: Patient ID in token response
Include launch/patient in scopes:
openid fhirUser launch launch/patient user/Patient.rs
Token response:
{
"access_token": "...",
"patient": "123"
}
const patientId = tokenResponse.patient;
if (patientId) {
// Fetch patient demographics
const patient = await fetch(
`${fhirBaseUrl}/Patient/${patientId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
// Fetch patient's observations
const observations = await fetch(
`${fhirBaseUrl}/Observation?patient=${patientId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
} else {
// No patient context - prompt user to select patient
showPatientSelector();
}
Token response (when encounter available):
{
"access_token": "...",
"patient": "123",
"encounter": "456"
}
Token response (no active encounter):
{
"access_token": "...",
"patient": "123"
}
Note: encounter field only present if user is in an active encounter.
const patientId = tokenResponse.patient;
const encounterId = tokenResponse.encounter;
// Always fetch patient
const patient = await fetch(
`${fhirBaseUrl}/Patient/${patientId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
if (encounterId) {
// Encounter context available - fetch encounter details
const encounter = await fetch(
`${fhirBaseUrl}/Encounter/${encounterId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
// Fetch encounter-specific data
const encounterObservations = await fetch(
`${fhirBaseUrl}/Observation?patient=${patientId}&encounter=${encounterId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
// Display encounter-specific UI
showEncounterView(encounter, encounterObservations);
} else {
// No encounter context - show all patient data
showPatientView(patient);
}
Apps that benefit from encounter context:
Clinical Documentation
Order Entry
Clinical Decision Support
Quality Measures
Billing/Coding
Always check for encounter presence:
function handleLaunchContext(tokenResponse) {
const patientId = tokenResponse.patient;
const encounterId = tokenResponse.encounter;
if (!patientId) {
throw new Error('Patient context required but not provided');
}
if (encounterId) {
// Encounter-specific mode
return {
mode: 'encounter',
patientId: patientId,
encounterId: encounterId
};
} else {
// Patient-level mode (no active encounter)
return {
mode: 'patient',
patientId: patientId
};
}
}
// Use context
const context = handleLaunchContext(tokenResponse);
if (context.mode === 'encounter') {
// Show encounter-specific features
enableEncounterDocumentation();
loadEncounterData(context.patientId, context.encounterId);
} else {
// Show patient-level features
enablePatientView();
loadPatientData(context.patientId);
}
Scope: fhirUser
Provides: Practitioner or Patient reference for authenticated user
Include fhirUser in scopes:
openid fhirUser launch user/Patient.rs
Token response:
{
"access_token": "...",
"fhirUser": "Practitioner/789"
}
Or for patient:
{
"access_token": "...",
"fhirUser": "Patient/123"
}
Or for staff that are not Practitioners:
{
"access_token": "...",
"fhirUser": "Person/123"
}
const fhirUser = tokenResponse.fhirUser;
// Parse user reference
const [resourceType, userId] = fhirUser.split('/');
if (resourceType === 'Practitioner') {
// Fetch practitioner details
const practitioner = await fetch(
`${fhirBaseUrl}/Practitioner/${userId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
// Display provider name in UI
displayProviderInfo(practitioner);
} else if (resourceType === 'Patient') {
// Patient user
const patient = await fetch(
`${fhirBaseUrl}/Patient/${userId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
displayPatientInfo(patient);
} else if (resourceType === 'Person') {
// Person user
const person = await fetch(
`${fhirBaseUrl}/Person/${userId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` }}
);
displayPersonInfo(person);
}
| Context | Scope | Field | Example Value | When Available |
|---|---|---|---|---|
| Patient | launch/patient | patient | "123" | EHR launch, patient apps |
| User | fhirUser | fhirUser | "Practitioner/789" | All authenticated launches |
Example token response with all contexts:
{
"access_token": "eyJ0eXAiOiJKV1Qi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid fhirUser launch launch/patient launch/encounter user/Patient.rs user/Encounter.rs",
"patient": "123",
"encounter": "456",
"fhirUser": "Practitioner/789"
}
GET /fhir/.well-known/smart-configuration
No authentication required.
curl -X GET 'https://localhost:9300/apis/default/fhir/.well-known/smart-configuration' \
-H 'Accept: application/json'
{
"issuer": "https://localhost:9300/oauth2/default",
"authorization_endpoint": "https://localhost:9300/oauth2/default/authorize",
"token_endpoint": "https://localhost:9300/oauth2/default/token",
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"private_key_jwt"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials"
],
"registration_endpoint": "https://localhost:9300/oauth2/default/registration",
"scopes_supported": [
"openid",
"fhirUser",
"launch",
"launch/patient",
"launch/encounter",
"offline_access",
"online_access",
"patient/Patient.rs",
"user/Patient.rs",
"system/Patient.rs"
],
"response_types_supported": ["code"],
"capabilities": [
"launch-ehr",
"launch-standalone",
"client-public",
"client-confidential-symmetric",
"client-confidential-asymmetric",
"context-banner",
"context-style",
"context-ehr-patient",
"context-ehr-encounter",
"sso-openid-connect",
"permission-offline",
"permission-patient",
"permission-user",
"permission-v1",
"permission-v2"
],
"code_challenge_methods_supported": ["S256"],
"introspection_endpoint": "https://localhost:9300/oauth2/default/introspect",
"revocation_endpoint": "https://localhost:9300/oauth2/default/revoke"
}
| Capability | Description |
|---|---|
launch-ehr | Supports EHR launch flow |
launch-standalone | Supports standalone launch flow |
client-public | Supports public clients (PKCE required) |
client-confidential-symmetric | Supports client secrets |
client-confidential-asymmetric | Supports JWKS authentication |
context-ehr-patient | Provides patient context in EHR launch |
context-ehr-encounter | Provides encounter context in EHR launch ✨ NEW |
sso-openid-connect | OpenID Connect single sign-on |
permission-offline | Offline access (refresh tokens) |
permission-patient | Patient-level scopes supported |
permission-user | User-level scopes supported |
authorize-post | Pass data via POST directly to authorization endpoint instead of via GET ✨ NEW |
permission-v1 | SMART v1 scopes (Backwards compatibility) ✨ NEW |
permission-v2 | SMART v2 scopes (granular permissions) ✨ NEW |
Dynamic app configuration:
class SMARTApp {
async initialize(fhirBaseUrl) {
// Discover SMART configuration
const configUrl = `${fhirBaseUrl}/.well-known/smart-configuration`;
const config = await fetch(configUrl).then(r => r.json());
// Store endpoints
this.authorizationEndpoint = config.authorization_endpoint;
this.tokenEndpoint = config.token_endpoint;
this.registrationEndpoint = config.registration_endpoint;
// Check capabilities
this.supportsEHRLaunch = config.capabilities.includes('launch-ehr');
this.supportsEncounterContext = config.capabilities.includes('context-ehr-encounter');
this.supportsGranularScopes = config.capabilities.includes('permission-v2');
// Check PKCE requirement
this.requiresPKCE = config.code_challenge_methods_supported?.includes('S256');
console.log('SMART configuration loaded:', {
ehrLaunch: this.supportsEHRLaunch,
encounterContext: this.supportsEncounterContext,
granularScopes: this.supportsGranularScopes
});
}
buildAuthorizationUrl(scopes) {
const url = new URL(this.authorizationEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.clientId);
url.searchParams.set('redirect_uri', this.redirectUri);
// Use granular scopes if supported
if (this.supportsGranularScopes) {
url.searchParams.set('scope', scopes.join(' '));
} else {
// Fall back to v1 scopes
const v1Scopes = scopes.map(s => s.replace('.rs', '.read'));
url.searchParams.set('scope', v1Scopes.join(' '));
}
return url.toString();
}
}
Two discovery endpoints:
GET /oauth2/default/.well-known/openid-configuration
GET /fhir/.well-known/smart-configuration
SMART apps should use: SMART configuration endpoint
Benefits:
SMART supports native applications (mobile, desktop) with additional security requirements.
{
"application_type": "public",
"client_name": "My Mobile App"
}
No client secret - Cannot be securely stored in native apps.
Proof Key for Code Exchange (PKCE) is mandatory:
// Generate code verifier
const codeVerifier = generateRandomString(128);
sessionStorage.setItem('code_verifier', codeVerifier);
// Generate code challenge
const codeChallenge = await sha256(codeVerifier).then(base64url);
// Include in authorization request
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
See Authentication Guide for implementation.
Redirect URI should use custom scheme:
com.example.myapp://callback
myapp://oauth/callback
Registration:
{
"redirect_uris": ["com.example.myapp://callback"]
}
Platform-specific:
Required: Store tokens securely
iOS:
Android:
Desktop:
Never:
Scope: offline_access
Native apps should request refresh tokens:
openid offline_access patient/Patient.rs patient/Observation.rs
Refresh token lifetime: 3 months
Rotation: New refresh token issued on each use
Recommended: Pin server certificates to prevent MITM attacks
// iOS example
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Verify certificate against pinned certificate
if verifyCertificate(serverTrust) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
import { authorize } from 'react-native-app-auth';
import * as Keychain from 'react-native-keychain';
class SMARTNativeApp {
async login() {
// SMART configuration
const config = {
issuer: 'https://localhost:9300/oauth2/default',
clientId: 'YOUR_CLIENT_ID',
redirectUrl: 'com.example.app://callback',
scopes: [
'openid',
'offline_access',
'patient/Patient.rs',
'patient/Observation.rs'
],
// PKCE automatically handled
usePKCE: true,
// Additional parameters
additionalParameters: {
aud: 'https://localhost:9300/apis/default/fhir'
}
};
try {
// Perform authorization
const result = await authorize(config);
// Securely store tokens
await Keychain.setGenericPassword(
'access_token',
result.accessToken,
{ service: 'smart_tokens' }
);
await Keychain.setGenericPassword(
'refresh_token',
result.refreshToken,
{ service: 'smart_tokens' }
);
// Extract context
const patientId = result.tokenAdditionalParameters?.patient;
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
patientId: patientId
};
} catch (error) {
console.error('Authorization failed:', error);
throw error;
}
}
async getAccessToken() {
// Retrieve from secure storage
const credentials = await Keychain.getGenericPassword({
service: 'smart_tokens'
});
if (credentials) {
return credentials.password; // access_token
}
throw new Error('No access token found');
}
async refreshAccessToken() {
// Implement refresh token flow
// See Authentication Guide for details
}
}
✅ Always use PKCE - Required for public clients ✅ Secure token storage - Use platform-specific secure storage ✅ Certificate pinning - Prevent MITM attacks ✅ Short-lived tokens - Request refresh tokens for long-term access ✅ Token rotation - Handle refresh token rotation ✅ Error handling - Gracefully handle token expiration ✅ Deep linking - Handle custom URI scheme properly ✅ Network security - Use TLS, validate certificates
❌ Don't hardcode secrets - No client secrets in native apps ❌ Don't log tokens - Prevent token leakage ❌ Don't use embedded browsers - Use system browser (ASWebAuthenticationSession, Chrome Custom Tabs)
Native apps must follow RFC 8252: OAuth 2.0 for Native Apps
Key requirements:
Administrators manage SMART apps through the OpenEMR interface.
After registration, apps must be enabled:
Steps:
Auto-enabled:
Require enabling:
Temporarily disable app without deleting:
Steps:
Effect:
Use when:
View app usage:
Information available:
See Authorization Guide - Revoking Access for:
✅ Use HTTPS only - All communication over TLS ✅ Validate state parameter - Prevent CSRF attacks ✅ Implement PKCE - Required for public apps, recommended for all ✅ Secure token storage - Never expose tokens ✅ Validate tokens - Use introspection for high-security operations ✅ Handle token expiration - Implement refresh flow ✅ Minimize scope requests - Request only necessary permissions ✅ Use granular scopes - Limit data access ✅ Validate redirect URIs - Ensure exact match ✅ Log security events - Track authorization attempts
❌ Don't expose client secrets - In public apps or client-side code ❌ Don't bypass SSL validation - Always validate certificates ❌ Don't store tokens in logs - Prevent token leakage ❌ Don't use wildcard redirects - Security risk ❌ Don't request excessive scopes - Follow least privilege
✅ Review app registrations - Verify app legitimacy ✅ Monitor app usage - Regular audits ✅ Enable manual approval - For maximum security ✅ Revoke suspicious apps - Act on security incidents ✅ Educate users - About app permissions ✅ Maintain audit logs - Track API access ✅ Update OpenEMR - Apply security patches
❌ Don't auto-approve system apps - Review carefully ❌ Don't ignore security alerts - Investigate promptly ❌ Don't share credentials - Each app gets unique ID
Authentication:
Authorization:
Data Protection:
Incident Response:
Symptoms: Authorization fails with invalid launch token error
Causes:
launch parameter in authorization requestSolutions:
Symptoms: Token response missing patient field
Causes:
launch/patient scope not requestedSolutions:
launch/patient scopeSymptoms: 403 Forbidden when accessing resources
Causes:
Solutions:
Symptoms: App not visible in SMART Enabled Apps
Causes:
Solutions:
Symptoms: Token exchange fails with PKCE error
Causes:
Solutions:
For authorization issues:
For launch issues:
launchFor token issues:
For context issues:
Next Steps:
Resources:
Support:
This documentation represents the collective knowledge and contributions of the OpenEMR open-source community. The content is based on:
The organization, structure, and presentation of this documentation was enhanced using Claude AI (Anthropic) to:
All technical accuracy is maintained from the original community-authored documentation.
OpenEMR is an open-source project. To contribute to this documentation:
Last Updated: November 2025 License: GPL v3
For complete documentation, see Documentation/api/