SMART on FHIR
This guide explains how to use Descope as an OAuth 2.0 provider for SMART on FHIR applications. SMART on FHIR is a healthcare industry standard that allows third-party applications to securely access Electronic Health Record (EHR) data.
Descope's Inbound Apps feature enables you to act as an OAuth provider for SMART apps, handling user authentication, consent, and token issuance while your application manages the SMART-specific launch context and FHIR API calls. You register your FHIR server as an API Resource that holds the SMART scopes, and the Inbound App for your SMART app requests its scopes from that Resource.
Overview
SMART on FHIR provides a standardized way for healthcare applications to:
- Launch from within an EHR system (EHR Launch) or as a standalone application (Standalone Launch)
- Request access to patient data with granular scopes
- Handle user authentication and authorization
- Access FHIR resources on behalf of authenticated users
Descope supports both EHR Launch and Standalone Launch flows through Inbound Apps, which provide:
/authorizeendpoint for user authentication and consent/tokenendpoint for token exchange- JWT templates for customizing access tokens with SMART-specific claims
- Scope management and validation
Architecture
In a SMART on FHIR integration with Descope:
- Your application receives launch requests from the EHR (for EHR Launch) or initiates authentication (for Standalone Launch)
- Descope acts as the OAuth authorization server, handling user authentication and consent
- Your application receives the access token and uses it to call the EHR's FHIR API
Descope issues the access token for the FHIR server Resource. The token's aud claim includes the Resource's audience, and its scope claim holds the SMART scopes the user approved. The token also includes SMART-specific claims (like patient, encounter) that your application forwards to the EHR's FHIR server.
Launch Types
EHR Launch
The app is launched from within the EHR. The EHR redirects to your app with a launch parameter containing an encrypted launch context token.
Flow:
- User clicks your app in the EHR
- EHR redirects to your app with
iss(EHR issuer URL) andlaunchparameters - Your app extracts launch context and redirects to Descope for authorization
- User authenticates and consents via Descope
- Descope redirects back with authorization code
- Your app exchanges code for access token
- Access token includes launch context (patient ID, encounter ID, etc.)
Standalone Launch
The app is launched independently, without being embedded in the EHR. Users authenticate directly.
Flow:
- User navigates directly to your app
- Your app redirects to Descope for authorization
- User authenticates and consents via Descope
- Descope redirects back with authorization code
- Your app exchanges code for access token
- Access token can be used to access FHIR resources (patient selection may happen later)
Integration Steps
1. Register Your FHIR Server as an API Resource
A Resource is the API that a client requests access to. For a SMART app, the Resource is your FHIR server. You define the SMART scopes once on the Resource, so every app that calls your FHIR server requests scopes from the same list.
- In the Descope Console, go to Resources.
- Click + Resource and choose API.
- Enter a Name for your FHIR server.
- Set Audience to your FHIR server's base URL, such as
https://fhir.example.com/fhir. - Under Scopes, add each SMART scope that your apps can request, with a description.
- Click Create.
For example, this FHIR server Resource defines read scopes for clinicians and patients, and one write scope for clinical notes:
| Scope | Description |
|---|---|
user/Patient.rs | Read and search patient demographic records you have access to |
user/Observation.rs | Read and search vital signs and other clinical observations |
user/DocumentReference.rs | Read and search clinical notes and visit transcripts |
user/DocumentReference.c | Create a new clinical note on a patient's chart |
patient/Patient.rs | Read your own demographic record |
patient/Observation.rs | Read your own vital signs and clinical observations |
patient/DocumentReference.rs | Read your own clinical notes and visit transcripts |
A user/ scope covers the records that the signed-in user can access, and a patient/ scope limits access to one patient. For the full scope syntax, see the SMART App Launch documentation.
You can leave Roles & Permissions empty on each scope. To limit a scope to certain roles, see Scopes on Resources.
2. Create an Inbound App for Your SMART App
The Inbound App is the OAuth client for your SMART app. The app does not define the FHIR resource scopes, such as patient/Patient.rs. Descope grants them from the FHIR server Resource when your app sends the resource parameter and a policy allows them.
The SMART scopes launch, launch/patient, fhirUser, and offline_access do not name a FHIR resource type, so they are not on the FHIR server Resource. You add them to the Inbound App as app-level attribute scopes. The openid scope needs no setup.
- In the Descope Console, go to Inbound Apps and click + Inbound App.
- Under Grant Types > Authorization Code, click Manage.
- Add your app's callback URL, such as
https://yourapp.com/oauth/callback, and click Add. - Under Attribute scopes, click Create app-level scope for each of
launch,launch/patient,fhirUser, andoffline_access. For standalone launch,launch/patientadds nopatientclaim, so set that claim in the JWT template from a custom attribute, such as{{user.customAttributes.fhirPatientId}}. - Give each attribute scope a Description, which the consent screen shows to the user.
- For
fhirUser, add a Dynamic claim with the KeyfhirUserthat reads a custom attribute holding each user's FHIR resource reference, such asuser.customAttributes.fhirUserwith a value likePatient/pt-alvarez. Create the custom attribute under Users first. - Click Save.
- Optionally, customize the consent flow to match your branding.
3. Allow the SMART Scopes with a Policy
A user can approve a Resource scope only when an active policy rule allows it for your app. Without a rule that allows the scopes, the user sees them on the consent screen, but clicking Authorize fails with "Failed to create consent, denied by rule".
- In the Descope Console, go to Access Policy, click + Rule, and enter a Rule name.
- Under Subjects, choose Select clients and select your Inbound App.
- Under Targets, choose Specified and select the FHIR server Resource.
- Select the scopes that this app can receive.
- Under Grant types, select User access.
- Click Create.
4. Configure JWT Template for SMART Claims
Create a JWT template that includes SMART-specific claims in the access token:
- In the Descope Console, go to Project Settings -> JWT Templates, and create a new template.
- Configure the template with SMART-specific claims:
{
"scope": "{{scopes}}",
"patient": "{{patient_id}}",
"encounter": "{{encounter_id}}",
"need_patient_banner": true,
"smart_style_url": "{{smart_style_url}}"
}Some of the key claims that you will need to configure are:
scope- The authorized scopes (automatically included)patient- Patient ID from launch context (for EHR Launch)encounter- Encounter ID from launch context (if available)need_patient_banner- Indicates if the app should display patient contextsmart_style_url- URL for SMART styling resources (optional)
- Under Project Settings -> Session Management, assign this JWT template as the User JWT
The template does not set aud. Descope adds the FHIR server Resource's Audience to the aud claim when your app names it in the resource parameter.
5. Handle Launch Parameters (EHR Launch)
When your app receives a launch request from the EHR, extract the launch context:
// Example: Express.js route handler
app.get('/launch', (req, res) => {
const { iss, launch } = req.query;
// Store launch context temporarily (e.g., in session or encrypted cookie)
// You'll need to decode the launch parameter to extract patient/encounter IDs
const launchContext = {
iss, // EHR issuer URL
launch, // Launch context token
state: generateRandomState()
};
// Store in session for later use
req.session.launchContext = launchContext;
// Redirect to Descope authorization
const authUrl = buildAuthorizationUrl(launchContext);
res.redirect(authUrl);
});6. Build Authorization Request
Construct the authorization request to Descope's /authorize endpoint. Set the resource parameter to the FHIR server Resource's Audience, so Descope issues the token for your FHIR server:
function buildAuthorizationUrl(launchContext: LaunchContext): string {
const params = new URLSearchParams({
client_id: process.env.DESCOPE_CLIENT_ID!,
redirect_uri: 'https://yourapp.com/oauth/callback',
response_type: 'code',
scope: 'openid fhirUser launch offline_access patient/Patient.rs patient/Observation.rs',
// The FHIR server Resource's Audience
resource: process.env.FHIR_SERVER_URL!,
state: launchContext.state,
// Include launch parameter for EHR Launch
...(launchContext.launch && { launch: launchContext.launch }),
// Include iss parameter
...(launchContext.iss && { iss: launchContext.iss })
});
return `__BaseURL__/oauth2/v1/apps/authorize?${params.toString()}`;
}7. Handle OAuth Callback
After user authentication and consent, Descope redirects back to your callback URL:
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches
if (state !== req.session.launchContext?.state) {
return res.status(400).send('Invalid state parameter');
}
// Exchange authorization code for access token
const tokenResponse = await exchangeCodeForToken(code as string);
// Extract launch context from token (if EHR Launch)
const launchContext = extractLaunchContext(tokenResponse);
// Store token and launch context
req.session.accessToken = tokenResponse.access_token;
req.session.patientId = launchContext.patient;
req.session.encounterId = launchContext.encounter;
// Redirect to your app's main interface
res.redirect('/app');
});8. Exchange Authorization Code for Token
Note
The client_secret comes from the Inbound App configuration.
Exchange the authorization code for an access token. Send the same resource value that you sent to /authorize:
async function exchangeCodeForToken(code: string) {
const response = await fetch('__BaseURL__/oauth2/v1/apps/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.DESCOPE_CLIENT_ID!,
client_secret: process.env.DESCOPE_CLIENT_SECRET!,
code: code,
redirect_uri: 'https://yourapp.com/oauth/callback',
resource: process.env.FHIR_SERVER_URL!,
}),
});
return await response.json();
}The token response includes:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "abc123...",
"scope": "openid fhirUser launch offline_access patient/Patient.rs patient/Observation.rs",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}9. Extract Launch Context from Token
Decode the access token to extract SMART-specific claims:
import jwt from 'jsonwebtoken';
function extractLaunchContext(tokenResponse: TokenResponse) {
const decoded = jwt.decode(tokenResponse.access_token, { complete: true });
const payload = decoded?.payload as any;
return {
patient: payload.patient,
encounter: payload.encounter,
need_patient_banner: payload.need_patient_banner,
smart_style_url: payload.smart_style_url,
};
}10. Use Access Token with FHIR API
Use the access token to call the EHR's FHIR API:
async function fetchPatientData(accessToken: string, patientId: string) {
const fhirServerUrl = process.env.FHIR_SERVER_URL!;
const response = await fetch(
`${fhirServerUrl}/Patient/${patientId}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/fhir+json',
},
}
);
return await response.json();
}Standalone Launch Example
For Standalone Launch, the flow is simpler since there's no launch context:
// User navigates directly to your app
app.get('/login', (req, res) => {
const authUrl = new URL('__BaseURL__/oauth2/v1/apps/authorize');
authUrl.searchParams.set('client_id', process.env.DESCOPE_CLIENT_ID!);
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/oauth/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid fhirUser launch/patient offline_access patient/Patient.rs patient/Observation.rs');
authUrl.searchParams.set('resource', process.env.FHIR_SERVER_URL!);
authUrl.searchParams.set('state', generateRandomState());
res.redirect(authUrl.toString());
});In a standalone launch, set the patient claim as described in Step 2.
Handling Launch Context
For EHR Launch, you need to decode the launch parameter to extract patient and encounter IDs. The launch parameter is typically a JWT or encrypted token provided by the EHR.
The exact format of the launch parameter varies by EHR. Some EHRs provide it as a simple identifier that you exchange for launch context via their API, while others provide it as a JWT. Consult your EHR's SMART on FHIR documentation for specifics.
Token Refresh
SMART on FHIR access tokens are typically short-lived. Use the refresh token to obtain a new access token, and send the same resource value:
async function refreshAccessToken(refreshToken: string) {
const response = await fetch('__BaseURL__/oauth2/v1/apps/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.DESCOPE_CLIENT_ID!,
client_secret: process.env.DESCOPE_CLIENT_SECRET!,
refresh_token: refreshToken,
resource: process.env.FHIR_SERVER_URL!,
}),
});
return await response.json();
}Troubleshooting
These are some common issues you might encounter when integrating with SMART on FHIR:
| Issue | Likely Cause | Solution |
|---|---|---|
Invalid launch parameter | Launch context expired or malformed | Ensure launch context is used immediately after receipt |
Missing patient claim in token | Launch context not properly extracted | Verify launch parameter decoding and JWT template configuration |
| FHIR API returns 401 | aud claim doesn't match FHIR server URL | Set the FHIR server Resource's Audience to the FHIR server URL, and send it as resource on authorize and token requests |
invalid_scope error that lists invalid=[...] scopes | The rule does not allow the listed scopes for this app | Allow the scopes in the rule, or remove them from the request |
Failed to create consent, denied by rule | No rule allows the requested FHIR server scopes for this app | Create a rule for the Inbound App that targets the FHIR server Resource |
| State mismatch | CSRF protection or session issue | Verify state parameter is properly stored and validated |