Guides and Tutorials/EHR & Healthcare App Integrations

Backend EHR Integrations

This guide explains how to integrate Descope with Epic, Meditech, OpenEMR, and other EHR systems.

This integration allows your backend service to securely authenticate with an EHR system using a signed JWT (client assertion) instead of a client secret. This is the standard backend-only / system-level SMART on FHIR flow, designed for cases where no end-user is present.

By handling JWT construction, signing, and EHR-specific token endpoint requirements, Descope removes the complexity typically involved in connecting to EHR platforms such as Epic, Meditech, OpenEMR, and other SMART on FHIR-compliant systems.

Overview

SMART Backend Services enable backend applications to authenticate to EHR systems without user interaction. Instead of using traditional client secrets, your service generates a signed JWT assertion using a private key.

The EHR validates this assertion using your public key and issues an access token. This approach allows you to request system-level scopes like system/*.read and access FHIR resources from automated server processes, scheduled jobs, or background services.

Descope supports both directions of this flow:

Outbound Flow

Descope generates signed JWT assertions and obtains EHR-issued access tokens that your backend can use to call FHIR APIs.

Inbound Flow

Descope can accept EHR-issued JWTs or access tokens and exchange them for Descope tokens. This enables you to:

  • Treat an EHR-issued token as an authenticated identity,
  • Map EHR users or service accounts into Descope identities,
  • Normalize and unify identity handling across multiple EHR systems.

Outbound Flow (Descope → EHR)

This flow lets your backend obtain an EHR access token to call FHIR APIs.

  1. Register your backend app in the EHR.
  2. Upload your Descope public key (JWKs) to the EHR.
  3. Use Descope to generate a client assertion JWT.
  4. Send the JWT to the EHR token endpoint with grant_type=jwt-bearer.
  5. The EHR validates the assertion and returns an access token.
  6. Use the access token to call FHIR endpoints.

1. Register Your Backend App in the EHR

Epic

  1. Log into Epic App Orchard / Connection Hub.
  2. Register a Backend Service / System App.
  3. Select required scopes (for example, system/*.read).
  4. Note the Client ID and Token URL.
  5. Upload your Descope Public Key (JWKs).

Other Supported EHR Systems

Descope supports integration with these EHR systems out of the box:

EHRToken EndpointAlgorithmNotes
Meditechhttps://<host>/tokenRS256Standard SMART backend services
Medplumhttps://<host>/oauth2/tokenRS256Ideal for testing & development
eClinicalWorks (eCW)https://<host>/oauth2/tokenRS256Standard OAuth2 implementation
OpenEMRhttps://<host>/oauth2/default/tokenRS384Requires RS384 signing algorithm

Descope automatically handles the correct signing algorithm, JWT claim structure, and token exchange format for each EHR system. For any SMART on FHIR-compliant EHR not listed here, Descope can be configured with custom token endpoints and signing algorithms.

2. Get Your Descope Public Key

Get your Descope public key from the project-level JWKs URL:

Note

Replace api.descope.com in the URL with your custom domain if applicable.

https://api.descope.com/<PROJECT_ID>/.well-known/JWKs.json

Descope automatically manages the private key used for signing. The JWKs endpoint contains the public key that EHR systems need to validate your client assertions. Upload this public key (or provide the JWKs URL) to your EHR system during app registration.

3. Generate a Client Assertion JWT

Note

JWT templates configured in your Descope Project do not apply to this client assertion JWT.

You have two options for generating the client assertion:

Note

The flattenAudience parameter is optional and will ensure the aud claim is a single string rather than an array.

import { DescopeClient } from '@descope/node-sdk';
 
const descopeClient = DescopeClient({
  projectId: process.env.DESCOPE_PROJECT_ID!,
  managementKey: process.env.DESCOPE_MANAGEMENT_KEY!,
});
 
async function generateEpicClientAssertion() {
  const clientId = process.env.EPIC_CLIENT_ID!;
  const tokenUrl = process.env.EPIC_TOKEN_URL!;
 
  const response = await descopeClient.management.jwt.generateClientAssertionJwt(
    clientId,           // issuer
    clientId,           // subject
    [tokenUrl],         // audience array
    300,                // expiresIn (seconds)
    true,               // flattenAudience (optional)
  );
 
  const clientAssertionJwt = response.data.jwt;
  return clientAssertionJwt;
}

Note

It is recommended to use a short lifetime in the expiresIn parameter for client assertions (for example, 300 seconds) to ensure the client assertion is not reused.

4. Exchange the Assertion for an EHR Access Token

Once you have the client assertion JWT from Descope, exchange it with the EHR:

POST <EHR_TOKEN_URL>
Content-Type: application/x-www-form-urlencoded
 
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<CLIENT_ASSERTION_JWT>

Example Epic response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 300
}

5. Use the Access Token to Call FHIR

Use the returned EHR access token in the Authorization header:

GET https://<epic-host>/api/FHIR/R4/Patient/123
Authorization: Bearer <EHR_ACCESS_TOKEN>
Accept: application/fhir+json

You can now perform FHIR operations allowed by the granted scopes (for example, system/*.read).

Example: End-to-End Outbound Flow

import axios from 'axios';
import { DescopeClient } from '@descope/node-sdk';
 
const descopeClient = DescopeClient({ projectId: process.env.DESCOPE_PROJECT_ID! });
 
async function getEhrAccessToken() {
  const clientId = process.env.EHR_CLIENT_ID!;
  const tokenUrl = process.env.EHR_TOKEN_URL!;
  const fhirBase = process.env.EHR_FHIR_BASE!;
 
  // 1. Generate client assertion with Descope
  const assertionResp = await descopeClient.management.jwt.generateClientAssertionJwt(
    clientId,
    clientId,
    [tokenUrl],
    300,
    true,
  );
 
  const clientAssertionJwt = assertionResp.data.jwt;
 
  // 2. Exchange assertion for EHR access token
  const params = new URLSearchParams({
    grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    client_assertion: clientAssertionJwt,
  });
 
  const tokenResponse = await axios.post(tokenUrl, params.toString(), {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  });
 
  const ehrAccessToken = tokenResponse.data.access_token as string;
 
  // 3. Use EHR access token to call FHIR
  const patientResp = await axios.get(`${fhirBase}/Patient/123`, {
    headers: {
      Authorization: `Bearer ${ehrAccessToken}`,
      Accept: 'application/fhir+json',
    },
  });
 
  return patientResp.data;
}
 
getEhrAccessToken().then(console.log).catch(console.error);

Multi-EHR / Multi-Tenant Patterns

For Descope projects that involve multiple tenants integrating with different EHR vendors:

  • Maintain a configuration map per tenant or per EHR. You can use tenant custom attributes to store the configuration, including:
    • EHR type (Epic, Meditech, OpenEMR, etc.)
    • Client ID
    • Token URL
    • FHIR base URL

Example config shape:

{
  "tenantA": {
    "ehrType": "epic",
    "clientId": "EPIC_CLIENT_ID",
    "tokenUrl": "https://epic.example.com/oauth2/token",
    "fhirBase": "https://epic.example.com/api/FHIR/R4"
  },
  "tenantB": {
    "ehrType": "openemr",
    "clientId": "OPENEMR_CLIENT_ID",
    "tokenUrl": "https://openemr.example.com/oauth2/default/token",
    "fhirBase": "https://openemr.example.com/apis/default/fhir/R4"
  }
}

Your backend can then look up the tenant attribute based on the user who is logged in, and then use the respective values to generate a client assertion JWT, as described above.

Inbound Flow (External JWT → Descope Token)

Note

This Inbound Flow requires a specific license. Please contact Descope Support to enable this feature for your project.

In the inbound flow, you receive a token from the EHR (or another SMART-compliant identity provider) and want to:

  • Accept it as user identity.
  • Map the subject to a Descope user.
  • Issue Descope tokens (session, access, refresh).
  • Normalize identity across multiple EHRs.

1. Configure External Token Validation

You can configure external token validation under the External Token Validation section of your Inbound App settings. If you do not already have an inbound application, you must create one first.

Add Inbound App -> JWT Bearer

Issuer URL

Enter the issuer URL for your EHR system. Some examples are:

  • Epic: https://fhir.epic.com/interconnect-fhir-oauth/oauth2
  • Meditech: https://<your-meditech-host>/oauth2
  • OpenEMR: https://<your-openemr-host>/oauth2/default

JWKs URL

You can manually override the JWKs URL if your EHR uses custom endpoints or if automatic discovery is not available.

Signing Algorithm

Select the signing algorithm used by your EHR system. The currently supported algorithms are:

  • RS256
  • ES384
  • ES256
  • ES512

User Information Endpoint URL

Optionally configure a /me or userinfo endpoint to pull additional user metadata after validating the JWT.

User Information LoginID Field Name

Choose which JWT claim identifies the user in Descope. Some examples are:

  • sub
  • fhirUser
  • Any nested claim (for example, claims.user.id)
  • A field from the /me response

Note

Currently, only the user identifier is supported. Other user attributes/information from the token cannot be mapped to user attributes in Descope.

2. Exchange the External Token with Descope

Note

EHR access tokens are typically short-lived tokens (5 - 15 minutes), and therefore must be exchanged quickly after generation.

To exchange the EHR-issued token for a Descope token:

POST https://api.descope.com/oauth2/v1/apps/token
Content-Type: application/x-www-form-urlencoded
 
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<EHR_ACCESS_TOKEN>
client_id=<DESCOPE_INBOUND_APP_CLIENT_ID>

Descope validates the external token and returns:

{
  "access_token": "<DESCOPE_ACCESS_TOKEN>",
  "expires_in": 3600,
  "token_type": "Bearer"
}

You can now use access_token as a standard Descope token in your APIs and frontends.

Troubleshooting

These are some common errors you might encounter when integrating with EHR systems:

Symptom / ErrorLikely CauseHow to Fix
invalid_clientissuer / subject don't match the registered client IDEnsure both issuer and subject match the EHR client ID exactly.
invalid_grant or invalid_requestaudience does not match token URL; or assertion expiredUse the exact token endpoint URL as audience; ensure expiresIn is valid.
Signature verification failedUsing the wrong algorithm or stale public keyConfirm RS256 vs RS384; re-upload JWKs; ensure you're using the right key.
401/403 when calling FHIR endpointsMissing or insufficient scopesConfirm the registered app has system/*.read or required resource scopes.
Works in one environment but not another (sandbox vs prod)Different token URLs, issuers, or keys per environmentUpdate config for each environment and ensure correct issuer/audience.

Testing & Sandbox Guidance

For development and testing:

  • Use EHR sandbox environments (Epic Sandbox, Medplum, etc.).
  • Start with read-only scopes such as system/*.read.
  • Create a simple health check in your backend that:
    1. Calls Descope to generate a client assertion.
    2. Exchanges it with the EHR for an access token.
    3. Calls a lightweight endpoint (for example, /metadata or /Patient).
    4. Returns success/failure and any relevant error messages.

This gives you a single endpoint to verify that Descope configuration and EHR configuration are all working end-to-end.

Was this helpful?