Guides and Tutorials/EHR & Healthcare App Integrations

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.

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:

  • /authorize endpoint for user authentication and consent
  • /token endpoint 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:

  1. Your application receives launch requests from the EHR (for EHR Launch) or initiates authentication (for Standalone Launch)
  2. Descope acts as the OAuth authorization server, handling user authentication and consent
  3. Your application receives the access token and uses it to call the EHR's FHIR API

The access token issued by Descope 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:

  1. User clicks your app in the EHR
  2. EHR redirects to your app with iss (EHR issuer URL) and launch parameters
  3. Your app extracts launch context and redirects to Descope for authorization
  4. User authenticates and consents via Descope
  5. Descope redirects back with authorization code
  6. Your app exchanges code for access token
  7. 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:

  1. User navigates directly to your app
  2. Your app redirects to Descope for authorization
  3. User authenticates and consents via Descope
  4. Descope redirects back with authorization code
  5. Your app exchanges code for access token
  6. Access token can be used to access FHIR resources (patient selection may happen later)

Integration Steps

1. Configure Your Inbound App in Descope

  1. In the Descope Console, navigate to Inbound Apps. Click + Inbound App.

  2. Configure the required scopes for your SMART app. Common SMART scopes include:

    • patient/*.rs - Read and search any resource for the current patient
    • patient/*.read - Read any resource for the current patient
    • user/*.rs - Read and search any resource for the current user
    • launch - Required for EHR Launch to receive launch context
    • openid - Standard OIDC scope
    • fhirUser - Retrieve information about the current logged-in user
    • offline_access - Request a refresh token

    For a complete list of SMART scopes, refer to the SMART App Launch documentation.

  3. Set the redirect URI to your application's callback URL (e.g., https://yourapp.com/oauth/callback)

  4. Optionally customize your consent flow to match your branding

2. Configure JWT Template for SMART Claims

Create a JWT template that includes SMART-specific claims in the access token:

  1. In the Descope Console, go to Project Settings -> JWT Templates, and create a new template.
  2. Configure the template with SMART-specific claims:
{
  "aud": "{{fhir_server_url}}",
  "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:

  • aud - The FHIR server URL (audience). This should match the EHR's FHIR endpoint
  • 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 context
  • smart_style_url - URL for SMART styling resources (optional)
  1. Under Project Settings -> Session Management, assign this JWT template as the User JWT

The aud claim must match the FHIR server URL where your app will make API calls. For multi-EHR scenarios, you may need to use dynamic values or create separate templates per EHR.

3. 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);
});

4. Build Authorization Request

Construct the authorization request to Descope's /authorize endpoint:

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 patient/*.read launch',
    state: launchContext.state,
    // Include launch parameter for EHR Launch
    ...(launchContext.launch && { launch: launchContext.launch }),
    // Include iss parameter
    ...(launchContext.iss && { iss: launchContext.iss })
  });
  
  return `https://api.descope.com/oauth2/v1/apps/authorize?${params.toString()}`;
}

5. 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');
});

6. Exchange Authorization Code for Token

Note

The client_secret comes from the Inbound App configuration.

Exchange the authorization code for an access token:

async function exchangeCodeForToken(code: string) {
  const response = await fetch('https://api.descope.com/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',
    }),
  });
  
  return await response.json();
}

The token response includes:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "abc123...",
  "scope": "openid fhirUser patient/*.read launch",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}

7. 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,
  };
}

8. 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('https://api.descope.com/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 patient/*.read');
  authUrl.searchParams.set('state', generateRandomState());
  
  res.redirect(authUrl.toString());
});
 
// After authentication, user may need to select a patient
// This is typically done through a patient picker interface

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:

async function refreshAccessToken(refreshToken: string) {
  const response = await fetch('https://api.descope.com/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,
    }),
  });
  
  return await response.json();
}

Troubleshooting

These are some common issues you might encounter when integrating with SMART on FHIR:

IssueLikely CauseSolution
Invalid launch parameterLaunch context expired or malformedEnsure launch context is used immediately after receipt
Missing patient claim in tokenLaunch context not properly extractedVerify launch parameter decoding and JWT template configuration
FHIR API returns 401aud claim doesn't match FHIR server URLEnsure JWT template aud claim matches the EHR's FHIR endpoint
Scope not grantedScope not configured in Inbound AppAdd required scope to Inbound App configuration
State mismatchCSRF protection or session issueVerify state parameter is properly stored and validated
Was this helpful?