Skip to content

Repository files navigation

CHIPPER Smart-Starter

Chipper logo

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

Status

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.

Who this is for

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.

Why EHR launch

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.


Before you start

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.

Quick start

npm install
cp .env.example .env.local     # fill in CLIENT_ID, REDIRECT_URI, ALLOWED_ISS, SESSION_SECRET
npm run dev                    # https on :3000

npm 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.


Hard-won lessons

Each of these cost real time to diagnose. They are the reason this repo exists.

1. Confidential client and JWK Set URL together mean private_key_jwt

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.

2. invalid_client tells you nothing

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.

3. Redirect URIs match exactly, and fail late

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.

4. You probably can't choose your scopes

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.

5. Sessions overflow the cookie limit, silently

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.

6. localhost may be unreachable from the EHR

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.

7. Send the vendor's client-ID header when discovering

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.

8. Never trust iss

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.

9. Backend (system-level) access is a different problem

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.


Configuration

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_BASE64

Publish public.pem as a JWK Set at a stable, public, TLS URL requiring no authentication, and register that URL with your vendor.

Tests

npm test

98 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.

Building on it

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.

Security notes

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.

Epic references

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.

License

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.

About

A minimal SMART on FHIR starter: EHR launch, patient context, FHIR reads, optional LLM. No vendor SDK.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Contributors

Languages