EHR integration with FHIR works easily in a sandbox and gets much harder after that. If your Node.js service needs to pull patient data on a schedule, with no user clicking an Allow button, use SMART on FHIR backend services auth. Your server signs a short JWT with its private key, exchanges it for an access token and then calls the FHIR API.
You may already have the sandbox demo running. Then you pointed it at a test hospital and got a 401, Observations with no values and a result set that stopped after the first page. This tutorial covers those parts.
How does SMART backend services auth work?
It is OAuth 2.0 client credentials, except you send a signed JWT instead of a client secret. You register a public key with the EHR, usually as a JWKS URL. Each time you need a token, you sign a JWT with the matching private key and POST it to the token endpoint.
The JWT needs iss and sub set to your client ID, aud set to the exact token endpoint URL, a unique jti, and an exp no more than five minutes away. The header carries alg (RS384 or ES384) and the kid of your key.
Look up the token endpoint through discovery. Don't hardcode it:
async function discover(fhirBase) {
const res = await fetch(`${fhirBase}/.well-known/smart-configuration`)
if (!res.ok) throw new Error(`Discovery failed: ${res.status}`)
return res.json() // token_endpoint, scopes_supported, ...
}
Then build the client assertion with the jose package:
import { SignJWT, importPKCS8 } from 'jose'
import { randomUUID } from 'node:crypto'
async function clientAssertion({ clientId, tokenUrl, privateKeyPem, kid }) {
const key = await importPKCS8(privateKeyPem, 'RS384')
return new SignJWT({})
.setProtectedHeader({ alg: 'RS384', kid, typ: 'JWT' })
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(tokenUrl)
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime('4m')
.sign(key)
}
Sandboxes often let small mistakes through: a trailing slash in aud, a reused jti, a five-minute-plus expiry. Production servers usually don't. Sign a fresh assertion for every token request and never cache the assertion itself.
What breaks when you skip the parts sandboxes hide?
Most of the damage is silent.
If your code skips paging, you store the first 50 lab results and nobody notices until a clinician asks why a trend chart stops in March. If you ask for a new token on every call, you hammer the token endpoint, and a hospital that sees that pattern can throttle or suspend your client. If your mapper expects every Observation to have a valueQuantity, it crashes on real charts or drops records without telling anyone.
Then comes go-live. Hospital integration teams work through a queue. If you fail their testing and go back to the end of that queue, a fix that took an afternoon can cost you weeks of calendar time.
Do you need backend services at all?
Not always. If your app only reads data while a clinician or patient is signed in, use the EHR launch or standalone launch flow instead, because the user's own session controls access. If you need a full population extract every night, look at Bulk Data $export first. It uses the same auth but returns NDJSON files, so you skip paging altogether.
If you are building a one-EHR demo against a sandbox, the code in this post is enough and you can ship it yourself.
How should you cache the access token?
Cache it in memory, renew it about a minute before it expires, and share one in-flight request across callers. Backend services tokens usually last from a few minutes to an hour and come with no refresh token. Without the shared promise, ten parallel workers will request ten tokens at once.
let cached = null
let inflight = null
async function getToken(cfg) {
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token
if (inflight) return inflight
inflight = (async () => {
const body = new URLSearchParams({
grant_type: 'client_credentials',
scope: cfg.scope, // e.g. 'system/Patient.read system/Observation.read'
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: await clientAssertion(cfg),
})
const res = await fetch(cfg.tokenUrl, { method: 'POST', body })
if (!res.ok) throw new Error(`Token error ${res.status}: ${await res.text()}`)
const json = await res.json()
cached = { token: json.access_token, expiresAt: Date.now() + (json.expires_in ?? 300) * 1000 }
return cached.token
})().finally(() => { inflight = null })
return inflight
}
Check scopes_supported from discovery. SMART v1 servers expect system/Patient.read, and v2 servers expect system/Patient.rs. Also read the scope field in the token response, because the server may grant less than you asked for.
How do you page through a FHIR Bundle without losing records?
Follow the link entry whose relation is next until it disappears, and use that URL exactly as the server returned it. Don't rebuild it with _offset or page numbers. Many servers use opaque cursors that expire.
async function fhirGet(cfg, url, retried = false) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${await getToken(cfg)}`, Accept: 'application/fhir+json' },
})
if (res.status === 401 && !retried) {
cached = null
return fhirGet(cfg, url, true)
}
if (res.status === 429 && !retried) {
const wait = Number(res.headers.get('retry-after') || 5) * 1000
await new Promise(r => setTimeout(r, wait))
return fhirGet(cfg, url, true)
}
if (!res.ok) throw new Error(`FHIR ${res.status} on ${url}`)
return res.json()
}
async function* searchAll(cfg, firstUrl) {
let url = firstUrl
while (url) {
const bundle = await fhirGet(cfg, url)
for (const entry of bundle.entry ?? []) {
if (entry.search?.mode === 'outcome') continue // OperationOutcome warnings
yield entry.resource
}
url = bundle.link?.find(l => l.relation === 'next')?.url
}
}
Three details matter here. Entries with search.mode set to outcome hold warnings, not patient data, so log their codes and move on. Bundle.total is optional and often missing, so don't use it as your loop condition. The async generator keeps memory flat even when a patient has thousands of Observations.
How do you handle missing data and heavy extensions?
Treat every field as optional and look up extensions by URL, never by array position. Sandbox patients are tidy synthetic records. Real charts have Observations with a dataAbsentReason and no value, patients without a birth date, local codes with no LOINC mapping and race data inside US Core extensions.
const RACE = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race'
const getExt = (res, url) => res.extension?.find(e => e.url === url)
function patientRace(patient) {
return getExt(patient, RACE)?.extension?.find(e => e.url === 'text')?.valueString ?? null
}
function observationValue(obs) {
if (obs.valueQuantity) return { value: obs.valueQuantity.value, unit: obs.valueQuantity.unit }
if (obs.valueCodeableConcept) return { text: obs.valueCodeableConcept.text }
if (obs.valueString) return { text: obs.valueString }
if (obs.dataAbsentReason) return { absent: obs.dataAbsentReason.coding?.[0]?.code }
return { absent: 'unknown' }
}
Store the raw resource JSON next to your mapped fields so you can remap later without pulling the data again. Match codes on system plus code, and put codes you don't recognise in a review queue instead of dropping them. References can point to resource types outside your scopes, so resolve them only when you need them and handle a 403.
What changes when you move to a real hospital's production endpoint?
The code barely changes. Everything around it does, so check each of these before go-live:
- A new client ID and key for each environment. The hospital registers your app separately for production and someone on their side has to activate it. A working sandbox key proves nothing about production.
- A base URL for each site, found through discovery. Two hospitals on the same EHR vendor can run different versions and expose different resources.
- Narrower scopes. Ask only for what your feature needs. Security reviewers question every extra scope you request.
- Patient matching by identifier. Search with the MRN and the hospital's identifier system URI, never by name and birth date.
-
Key rotation through a JWKS URL. Publish the new key next to the old one under a new
kid, switch over, then remove the old key. If you uploaded a single key by hand, rotating it means an outage. - No PHI in logs. Log request IDs, resource counts and OperationOutcome codes, never Bundle bodies. You will also need a BAA and an audit trail.
-
Throttling. Respect
Retry-After, cap concurrency and run large pulls outside clinic hours.
If you are planning an EHR integration and want someone to check your auth flow, data mapping or go-live plan, Geminate Solutions offers a free review and replies within 24 hours. For the wider product picture, read the healthcare app development guide.
Top comments (0)