A minimal, working SMART on FHIR app. A clinician launches it from inside their EHR, it reads the patient in front of them, and optionally asks an LLM about them.
Seven routes, two config files, and a self-contained SMART client. No vendor SDK. Meant to be read in one sitting, then stripped or built on.
app/page.tsx landing; accepts a launch at the app root
app/layout.tsx HTML shell and styles
app/launch/route.ts validates iss, builds the PKCE request, redirects to the EHR
app/smart/callback/route.ts exchanges the code for a token, starts the session
app/dashboard/page.tsx shows the user, patient and conditions — copy this shape
app/api/fhir/[...path]/route.ts read-only FHIR proxy so the browser never holds a token
app/api/chat/route.ts OPTIONAL LLM; inert unless an API key is set
lib/config.ts all EHR-specific settings, from the environment
lib/session.ts signed, httpOnly cookie session
lib/smart/ the SMART client itself (~950 lines; one dependency, jose)
test/ 98 tests over the auth and FHIR layers
Contents — Status · Who this is for · Why EHR launch · Before you start · Quick start · Hard-won lessons · Configuration · Tests · Building on it · Security notes · Epic references · License
Verified end to end against a live Epic instance (May 2026, SMART v2) in August
2026: launched from a Hyperspace activity, authenticated with private_key_jwt,
and read the patient in context — 67 granted scopes, patient and encounter
context, and the clinician identified via fhirUser.
Anyone at a health system trying to build a clinician-facing app on their EHR's FHIR API and finding that the hard part isn't the code — it's the twenty configuration details that fail silently.
It was built against Epic, but nothing outside lib/smart/ mentions a
vendor. SMART on FHIR is an HL7 standard; the flow is the same on Cerner,
Meditech, and others. The gotchas in Hard-won lessons are
where vendors differ.
There are two ways a SMART app can start.
EHR launch — the user is already signed into the EHR, picks your app from
a menu or activity, and the EHR hands it a one-time launch token plus iss
(which FHIR server to talk to). No login screen. This is what you want for a
clinician-facing app.
Standalone launch — the user opens your app cold and it sends them to the EHR to sign in. Simpler to test, but it drags in the login page and, at many institutions, multi-factor enrollment.
This starter does both, and defaults to EHR launch.
You need a registered app with your EHR vendor before any of this runs. The
code cannot invent a client ID, and registration is the slow part — changes take
up to an hour to sync in a vendor sandbox and up to 12 hours in a customer
environment. Start here, not with npm install.
Register:
| Setting | Value |
|---|---|
| Launch URL | https://your-host/ (or /launch — both work) |
| Redirect URI | https://your-host/smart/callback |
| Scopes | launch openid fhirUser — see scopes |
Registration hands you a client ID. If your vendor also asks for a JWK Set URL, your app is a confidential client and you will need a signing key — lesson 1 explains why, and Configuration shows how to generate one.
Requires Node.js 20 or newer.
npm install
cp .env.example .env.local # fill in CLIENT_ID, REDIRECT_URI, ALLOWED_ISS, SESSION_SECRET
npm run dev # https on :3000npm run dev uses Next's --experimental-https, because registered redirect
URIs are almost always https — including on localhost. Your browser will warn
about the self-signed certificate once.
Then launch the app from your EHR, or visit /launch to try the standalone
flow. If a real EHR launch never reaches you, read
lesson 6 before debugging
anything else.
Each of these cost real time to diagnose. They are the reason this repo exists.
If your app is registered as a confidential client and the vendor asked you for a JWK Set URL, those two settings travel together: the token exchange must present a signed client assertion, not a client secret and not nothing.
Against Epic, every other method fails and the errors do not say why:
| Client authentication | Result |
|---|---|
| Public client, PKCE only | 400 invalid_client |
client_secret_basic |
401 invalid_client |
client_secret_post |
400 server_error |
private_key_jwt |
works |
Set PRIVATE_KEY_BASE64 and KEY_ID and this starter signs the assertion for
you. The public half must be published at the JWK Set URL you registered, under
that same kid.
A browser cannot hold a private key. If your app is a confidential client, the token exchange must happen server-side. A pure client-side SMART implementation cannot authenticate.
Epic returns 400 invalid_client with a null description for any unusable
authorization code — including for a client ID that does not exist. It cannot
distinguish a wrong client, a wrong auth method, a stale code, or a bad
redirect URI.
Do not debug it by sending synthetic requests; the answer is the same no matter what you send. Log the token endpoint, redirect URI, client ID and auth method server-side and read them from a real launch. This starter does that for you.
The comparison includes the scheme. http://localhost:3000/smart/callback
and https://localhost:3000/smart/callback are different URIs.
Worse, the check happens after the user signs in, and surfaces as a generic "Invalid OAuth 2.0 request" naming no parameter. If you see that, check the scheme first. Registration changes can take an hour to sync in a vendor sandbox and up to 12 hours in a production-adjacent environment.
The scope echoed on the redirect to the login page is not what you were
granted — it shows only launch openid fhirUser no matter what you ask for.
The real grant arrives in the token response, and it comes from your app
registration, not your request. Ours came back with 67 entries
(user/Patient.r, user/Condition.r, user/Observation.r, …) after the
redirect had suggested we had none.
So: request little, read scope from the token response, and don't diagnose a
scope problem from the authorize redirect.
That 67-scope string is ~3 KB. Add an access token and an id_token and a
cookie session exceeds the browser's ~4 KB limit — at which point the browser
discards it without any error. The symptom is a perfect token exchange
followed by a page saying "not signed in."
lib/session.ts drops the id_token (after extracting fhirUser), truncates
scope, and logs loudly if a session still won't fit. For multiple users, move
to a server-side store keyed by an opaque id.
If clinicians reach the EHR through Citrix or VDI, the browser it launches runs
on that host — so localhost is the Citrix server, not your laptop, and a
loopback launch URL goes nowhere. You have to host the app to test a real
EHR launch. Loopback is still fine for standalone testing.
Endpoints come from {iss}/.well-known/smart-configuration (or {iss}/metadata
with Accept: application/fhir+json). Epic lets institutions override endpoints
per client ID, so discovery without the Epic-Client-ID header can hand you
the wrong token URL. lib/smart/discovery.ts sends it.
iss arrives from the launch and says which FHIR server to talk to. A forged
launch could point it at an attacker's server and capture your authorization
code. Only servers in ALLOWED_ISS are accepted — the app refuses to start a
launch otherwise.
client_credentials for unattended jobs is a separate registration with its own
failure modes. If your institution fronts its FHIR server with a proxy, the
token URL you POST to may not be one the authorization server recognises as a
valid audience, and it rejects the assertion. The fix is an administrator adding
that URL to an audience allowlist — nothing you can do in code.
User-context auth (what this starter does) is unaffected, because it signs no assertion against that audience.
Everything is environment-driven; see .env.example.
| Variable | Required | Purpose |
|---|---|---|
CLIENT_ID |
yes | From your app registration |
REDIRECT_URI |
yes | Must match a registered redirect URI exactly |
ALLOWED_ISS |
yes | Comma-separated FHIR base URLs you accept launches from |
SESSION_SECRET |
yes | ≥32 chars; signs the session cookie |
APP_BASE_URL |
hosted | Your public origin, if behind a proxy |
PRIVATE_KEY_BASE64 |
confidential clients | base64 PKCS#8 PEM for private_key_jwt |
KEY_ID |
confidential clients | kid published at your JWK Set URL |
CLIENT_SECRET |
rarely | Only for confidential clients using a shared secret |
SCOPES |
no | Defaults to launch openid fhirUser |
FHIR_BASE_URL |
no | For standalone launch, when no iss is supplied |
ANTHROPIC_API_KEY |
no | Enables the optional LLM route |
ANTHROPIC_MODEL |
no | Defaults to claude-sonnet-5 |
Generate a key pair for private_key_jwt:
openssl genrsa 4096 | openssl pkcs8 -topk8 -nocrypt -out private.pem
openssl rsa -in private.pem -pubout -out public.pem
base64 -i private.pem | tr -d '\n' # -> PRIVATE_KEY_BASE64Publish public.pem as a JWK Set at a stable, public, TLS URL requiring no
authentication, and register that URL with your vendor.
npm test98 tests over the auth and FHIR layers, with no network and no EHR — signed assertions are verified against a throwaway keypair generated per run, and every token request is asserted on as a request: whether a client assertion was attached, whether a secret ended up in the body or the header, which endpoint was actually called.
They are worth reading before you change anything in lib/smart/. Several
encode a specific failure that cost real time — the 4-minute assertion
lifetime, the oversized session cookie a browser discards in silence, the
empty-bodied 403 that means a missing Incoming API. The comments say why, not
what.
Add a FHIR query — in a server component, createSmartClient({ session })
then client.get(), client.search(), or client.getAllPages(). From the
browser, call /api/fhir/<path> so the token stays server-side.
Add an LLM — set ANTHROPIC_API_KEY, or delete app/api/chat/route.ts.
Deploy — see DEPLOYING.md for build, environment, Azure App Service, Docker, and how to verify a deployment without a real EHR launch.
The access token lives in an httpOnly, signed, secure cookie the browser
cannot read. The FHIR proxy is GET only, so a UI bug cannot write to a
chart. iss is validated against an allowlist. SESSION_SECRET must be at
least 32 characters or the app refuses to start.
Sessions last as long as the access token unless the EHR granted a refresh
token, which requires asking for online_access or offline_access. Refreshes
authenticate the same way the original code exchange did, and are written back
to the cookie — but only from a Route Handler, because a Server Component
cannot set cookies. That asymmetry is one of several reasons a multi-user
deployment should move sessions to a server-side store keyed by an opaque id,
and keep tokens off the client entirely.
The LLM route sends only a small, named set of fields — not raw charts — and the
model cannot call FHIR itself. Every field you add to buildContext is PHI
leaving your network. Widen it deliberately, and check what your institution's
AI governance requires first.
This is a starter, not a compliance artifact. Auditing, consent, and access review are yours to add. Per the licence, it is provided for research and educational purposes and not for clinical, diagnostic, therapeutic, or patient-care use absent a separate written agreement with CHOP.
Vendor documentation that covers what this README cannot — the registration screens, the approval workflow, and how an embedded app behaves inside Hyperspace. Both sites are gated, and the two audiences differ:
- vendorservices.epic.com — for app developers, with a Vendor Services account
- galaxy.epic.com — Epic's customer UserWeb, so you need access through your own institution. If you are at a health system, your Epic team can pull these for you.
| Document | Why it matters here |
|---|---|
| Client ID Creation and Validation | How client IDs are issued and validated, and how they reach a customer environment — the ground truth behind lessons 1 and 2 |
| Embedding your Web App into Hyperspace | Behaviour inside Epic's embedded browser, which is not the same as a normal one. Includes a testing harness worth using before you demo — see lesson 6 |
| Integrating External Web Applications into Epic — Setup and Support Guide | The build your Epic team performs: the FDI record, the launch URL, and where the app appears in an activity |
The third one is the document to hand your Epic analyst. Most of what this starter cannot do for you — creating the FDI record, setting the launch URL, attaching the app to an activity — lives there.
Copyright © 2026 The Children's Hospital of Philadelphia. All rights reserved.
Dual-licensed. Academic and non-profit institutions may use, copy, and modify this software for internal, non-commercial research and educational purposes. Commercial use of any kind requires a separate written license from CHOP — contact the Office of Technology Transfer at techtransfer@chop.edu.
See LICENSE.md for the full terms. Note in particular that the software is provided for research and educational purposes and not for clinical, diagnostic, therapeutic, or patient-care use absent a separate written agreement.
