You've built a health app, and now someone asks the question that stalls every roadmap: "can it talk to our EHR?" The answer the industry standardized on is SMART-on-FHIR. It sounds like two more acronyms to learn — it's really just one good idea: a standard way for your app to authenticate a user and read or write clinical data, with permission, against any compliant system.
FHIR vs SMART: the one-line split
FHIR is the data layer — a REST API for clinical resources (Patient, Observation, DocumentReference…). SMART (Substitutable Medical Applications, Reusable Technologies) is the auth layer on top of it: an OAuth2 + OpenID Connect profile that says how your app logs a user in, gets a token, and is granted a scope of access to that FHIR data. If FHIR is "the clinical database with a REST API," SMART is "Sign in with the EHR, and here's exactly what you're allowed to touch." FHIR without SMART is data with no doorway; SMART without FHIR is a login with nothing to read.
The two launches (this is the part people conflate)
- EHR launch — your app opens inside an EHR (Epic, Cerner/Oracle, athenahealth). The EHR hands your app a launch context ("you're looking at patient 123, clinician Dr. Okafor") and your app exchanges it for a scoped token. This is how an app appears as a tab/button in a clinician's existing workflow.
- Standalone launch — your app launches on its own (a patient opens your mobile app), sends the user to an authorization server to log in, and gets back a token scoped to their data. No EHR session required.
Most app builders need standalone launch first (your own product, your own users) and reach for EHR launch later (distribution inside a health system). They share the same machinery below — the only real difference is where the launch context comes from.
EHR launch, end to end
This is the flow that runs when a clinician clicks your app's button inside an EHR. Walk it once and the rest of SMART makes sense.
- The EHR launches your app with a
launchtoken and aniss. It opens your registered launch URL with two query params:iss(the base URL of the EHR's FHIR server) and an opaquelaunchstring that carries the in-EHR context. - Discover the auth endpoints. Take that
issand fetch{iss}/.well-known/smart-configurationto learn the authorization and token endpoints (more on this document below). Never hardcode these — they differ per EHR and per tenant. - Redirect to
authorizewith the launch context + PKCE. Send the user to the authorization endpoint with yourclient_id,redirect_uri, thelaunchtoken, your requestedscope, a randomstate, a PKCEcode_challenge— and, critically, theaudparameter set to that sameissFHIR base URL. - User authenticates / authorizes. The EHR's authorization server confirms the user (already logged in, in EHR launch) and the requested scopes, then redirects back to your
redirect_uriwith an authorizationcodeand the originalstate. - Exchange the code for tokens. POST the
code, yourredirect_uri, and the PKCEcode_verifierto the token endpoint. Back comes anaccess_token, anid_token, optionally arefresh_token, and the launch context fields. - Read the patient context off the token response. The token response includes context like
patient(the FHIR id you were launched on) and the grantedscope. You did not have to ask the user who they're looking at — the EHR told you. - Call FHIR. Use the
access_tokenas a bearer token against{iss}:GET {iss}/Observation?patient={patient}&category=vital-signs, and so on, within the scopes you were granted.
The #1 mistake in this whole dance is getting aud and iss wrong. iss is where you discover and read FHIR; aud is the value you send to authorize declaring which FHIR server this token is for. They must match the EHR's FHIR base URL. Omit aud, or point it at the wrong base, and conformant servers will reject the request — by design, because that check is what stops a token minted for one server from being replayed against another.
Standalone launch, end to end
Standalone launch is the same flow with one piece removed: there is no inbound launch token, because nobody launched you from inside an EHR. Your app starts cold and asks for context itself.
- You already know the FHIR base (
iss). It's your own server, or a server the user picked. Fetch its/.well-known/smart-configurationthe same way. - Redirect to
authorize— withlaunch/patient, not a launch token. Because there's no EHR context to inherit, you request thelaunch/patientscope, which tells the authorization server "select a patient for me as part of login." Includeaud,state, and PKCE exactly as before. - User logs in and picks/consents to a patient. A patient app authenticates the patient and resolves to their own record; a provider app may show a patient picker.
- Code → token → context → FHIR. Identical to EHR launch from here: exchange the code (with the PKCE verifier), read the
patientcontext out of the token response, and call FHIR with the access token.
So the practical rule: EHR launch gives you context for free via the inbound launch token; standalone launch earns it by requesting the launch/patient scope. Same OAuth, same PKCE, same tokens.
The discovery document: /.well-known/smart-configuration
Everything above starts by reading one JSON file. Each FHIR server publishes a SMART discovery document at {base}/.well-known/smart-configuration that advertises its authorization and token endpoints and which capabilities it supports — PKCE methods, launch contexts, the scope grammar, and more. Your client reads this at runtime instead of hardcoding endpoints, which is exactly what makes a SMART app portable across EHRs. Here's an illustrative shape (values vary by server):
{
"issuer": "https://ehr.example.org/fhir",
"authorization_endpoint": "https://ehr.example.org/oauth/authorize",
"token_endpoint": "https://ehr.example.org/oauth/token",
"capabilities": [
"launch-ehr",
"launch-standalone",
"client-public",
"context-standalone-patient",
"permission-v2",
"permission-offline"
],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": [
"openid", "fhirUser", "launch", "launch/patient",
"offline_access", "patient/Observation.rs", "patient/*.rs"
],
"grant_types_supported": ["authorization_code", "refresh_token"]
} (Illustrative only — fetch the real document from the live server.) Two fields earn their keep: capabilities tells you whether the server speaks v1 or v2 scopes (permission-v1 / permission-v2) and whether it supports offline access; code_challenge_methods_supported should list S256 if it expects PKCE.
Scopes: v1 vs v2
Scopes are the contract for least-privilege access. A SMART scope has three parts: a context (patient/ for "this one patient," user/ for "everything this user can see," system/ for backend service access), a resource (Observation, * for all), and a set of permissions. The permission grammar is exactly what changed between versions.
- SMART v1 used coarse
.read/.write— e.g.patient/Observation.read. The problem: "read" bundled together fetching a single resource and running a search, and "write" bundled create, update, and delete. No way to grant "read but never delete." - SMART v2 splits permissions into granular letters:
create,read,update,delete,search. Sopatient/Observation.rsmeans "read + search this patient's Observations" and nothing else;patient/Observation.crudsis full access. This is what lets you actually express minimum-necessary.
| Intent | SMART v1 | SMART v2 |
|---|---|---|
| Read + search a patient's labs | patient/Observation.read | patient/Observation.rs |
| Record new observations only | patient/Observation.write (also allows update/delete) | patient/Observation.c |
| Full CRUD on a resource | patient/Observation.* | patient/Observation.cruds |
| Read everything in scope | patient/*.read | patient/*.rs |
Alongside the resource scopes are the non-resource scopes that carry identity and lifecycle, not data permissions:
openid+fhirUser— return an OpenID Connectid_tokenidentifying the logged-in user as a FHIR resource (aPractitionerorPatient). This is the "who is this person" half of SMART.launch— request the EHR launch context (used in EHR launch with the inboundlaunchtoken).launch/patient— request a patient context in standalone launch (the picker / self-resolution step above).offline_access— ask for arefresh_tokenso the app keeps working after the access token expires.
Mixing these up is a common failure: requesting v1 scopes from a v2-only server (or vice versa) gets you rejected or silently downgraded. Check capabilities in the discovery document and request the version the server advertises.
PKCE: why public clients can't keep a secret
A mobile app or single-page app is a public client — its code ships to the user's device or browser, so any "client secret" baked in is readable by anyone who downloads it. OAuth's classic flow assumed a confidential server-side secret; that assumption breaks for these clients. PKCE (Proof Key for Code Exchange) fixes it without a secret:
- Before redirecting, your app generates a random, high-entropy
code_verifier. - It hashes that with SHA-256 to produce a
code_challenge, and sends the challenge (pluscode_challenge_method=S256) to theauthorizeendpoint. - At the token exchange, it sends the original
code_verifier. The server re-hashes it and confirms it matches the challenge it stored.
The effect: even if an attacker intercepts the authorization code, they can't redeem it, because they never had the matching code_verifier. SMART 2.0 requires PKCE for all clients (with S256), so treat it as non-optional even for confidential clients. The verifier never leaves your app until the final back-channel call; the challenge is the only thing exposed in the redirect.
Tokens, refresh, and staying logged in
A successful token exchange returns up to three tokens, each with a distinct job:
access_token— the bearer token you put on FHIR requests. Short-lived (often minutes to an hour) and scoped to exactly what you were granted.id_token— only if you asked foropenid fhirUser. A signed OpenID Connect token identifying the user; decode it to learn who logged in (theirfhirUserreference).refresh_token— only if you asked foroffline_access. A long-lived token you exchange at the token endpoint for a freshaccess_tokenwhen the old one expires — without sending the user back through login.
The token response also carries the SMART context fields — most importantly patient (the launch patient's FHIR id) and the granted scope (which may be narrower than you requested, if the user or admin trimmed it). Read the granted scope and adapt your UI to it rather than assuming you got everything you asked for. To keep a patient logged in across sessions, request offline_access, store the refresh token securely (see pitfalls), and silently refresh on 401.
Conformance: proving it with Inferno
"We support SMART" is a claim until a conformance suite agrees. The reference suite is Inferno — the ONC conformance suite (built by MITRE), the open-source test kit that backs the US (g)(10) Standardized API certification criterion. Inferno drives a real SMART launch against your endpoints and checks the discovery document, the authorize/token flow, PKCE, scope handling, token introspection, and US Core data responses. Pointing Inferno at your server and reading the pass/fail is how you turn "interoperable" from a vibe into evidence.
Common pitfalls
audwrong or missing. The single most frequent failure.audmust equal the EHR's FHIR base URL (the same value asiss). Conformant servers reject mismatches to prevent token replay.- Requesting v1 scopes from a v2 server (or vice versa). Check
capabilitiesin the discovery document and request the version the server advertises; don't assume.readworks everywhere. - Forgetting
offline_access. No refresh token means the session dies when the access token expires — fine for a one-shot EHR launch, broken for an app a patient returns to. statemismatch. Generate a randomstate, store it, and verify it on the callback. Skipping it leaves you open to CSRF on the redirect.- Treating
launch/patientcontext as automatic in EHR launch. You still must request thelaunchscope and read thepatientfield off the token response — the context isn't injected into your session for free. - Insecure token storage. Don't park access/refresh tokens in
localStorageor plaintext on disk. Use platform secure storage (Keychain/Keystore) on mobile and HTTP-only, secure cookies or in-memory handling for web. - Assuming SMART scopes are per-record authorization. They aren't. SMART scopes are coarse — "this patient's Observations," "all of this user's data" — they don't express "this clinician may see these specific records under these conditions." Per-record, attribute-based authorization is a separate layer your app owns. See why building on FHIR is hard for what that gap costs.
A worked example: a tic-tracking app reading observations
Make it concrete. Say you're building a tic-tracking app for a movement-disorders clinic — the kind of focused tool bonfireDB is dogfooded against. A clinician opens it from inside their EHR to review a patient between visits.
The EHR launches your app with iss pointed at its FHIR base and a launch token. Your app fetches that server's /.well-known/smart-configuration, then redirects to authorize requesting launch openid fhirUser patient/Observation.rs offline_access, with aud set to the FHIR base and a PKCE challenge attached. The clinician is already authenticated in the EHR, so the authorization server redirects straight back with a code. Your app exchanges it (with the verifier) for an access token, reads patient off the token response, and calls GET {iss}/Observation?patient={patient}&code=... to pull the patient's recorded tic observations — each one a coded FHIR Observation, not a free-text blob. It charts the trend, and because it asked for offline_access, the clinician can reopen it tomorrow without re-launching. No new credential, no custom integration — the EHR vouched for the user and scoped exactly what the app could touch.
When you do NOT need SMART-on-FHIR
SMART solves a specific problem: third parties launching into, or reading from, a system they don't own. If that's not your situation, don't pay for it yet.
- No external EHR and no external clients. If you're building a self-contained app where you control both the data and every user, you need authentication and authorization — but not necessarily the full SMART launch profile. Add it when a real integration partner or app-marketplace requirement appears.
- You need bulk data, not a launch. Pulling a whole population for analytics or a data warehouse is a different profile: FHIR Bulk Data Access ($export), which uses SMART backend-services authentication (
system/scopes), not the interactive patient launch above. Don't shoehorn an interactive launch into a batch job.
The shortcut
None of the pieces above is exotic. The problem is that, done from scratch, they're weeks of undifferentiated plumbing — you stand up a FHIR server, bolt an OAuth/OIDC server onto it, wire v2 scopes to access rules, publish a discovery document, enforce PKCE, and chase US-Core conformance — all before your app does anything a user cares about. That's the work that makes "can it talk to our EHR?" turn into a quarter.
The pattern that works: build your app against typed clinical primitives, with canonical FHIR R4 underneath, and let the SMART surface come with it. That's the idea behind bonfireDB (open source, early access) — you write clinical.observations.record(...), FHIR is generated for export and interop, and shipping as a standalone SMART-on-FHIR app is designed to come with the box rather than be a quarter of plumbing. You ship the product; the interop surface is generated, not hand-rolled. For why building directly on a FHIR server hurts, see FHIR, explained; for the architecture, how it works.
Keep reading
- FHIR, explained for app developers — the data layer SMART sits on top of.
- Why build on FHIR at all? — the case for FHIR as your interop and export target.
- The agent-over-FHIR benchmark — why raw FHIR is hard for AI agents, and what we measure.
TL;DR
- FHIR = the clinical data API. SMART = the OAuth2 + OIDC layer that authenticates a user and scopes their access to it.
- Two launches: standalone (your own app, request
launch/patient) and EHR launch (inside Epic/Cerner/athena, context comes via the inboundlaunchtoken). Most builders need standalone first. - The flow: discover at
/.well-known/smart-configuration→ authorize withaud+ PKCE → code → token → context → call FHIR. Gettingaud/isswrong is the #1 mistake. - Scopes: v1
.read/.writevs v2 granular.rs/.cruds; plusopenid/fhirUser/launch/patient/offline_access. PKCE is required in SMART 2.0. - Prove conformance against ONC's Inferno (the (g)(10) basis) — it's the bar you aim at, not a stamp you start with.
- From scratch it's weeks of plumbing; the shortcut is to generate FHIR + the SMART surface from typed primitives.
Frequently asked questions
What is a SMART-on-FHIR app?
A SMART-on-FHIR app is a health app that uses the SMART OAuth2 and OpenID Connect profile to authenticate a user and read or write FHIR clinical data with scoped permission. It can launch inside an EHR or stand alone, and works against any compliant server without a custom integration per system.
What is the difference between FHIR and SMART?
FHIR is the data layer: a REST API for clinical resources like Patient and Observation. SMART is the auth layer on top of it: the OAuth2 and OpenID Connect profile that says how an app logs a user in, gets a token, and is granted a scope of access. FHIR is the database with an API; SMART is the doorway and the permission contract.
What is the difference between EHR launch and standalone launch?
In EHR launch your app opens inside an EHR, which hands it a launch token carrying the patient and user context for free. In standalone launch your app starts cold with no EHR session and earns context by requesting the launch/patient scope. Both use the same OAuth flow, PKCE, and tokens; only the source of context differs.
What pieces does a SMART-on-FHIR app actually need?
Five: a discovery document at /.well-known/smart-configuration, OAuth2 authorize and token endpoints, PKCE for public clients, scopes that express least-privilege access, and a FHIR endpoint the token unlocks for read, search, and write. Get those right and any SMART-compliant client, including the Inferno conformance suite, can launch against you.
How do I make my health app interoperable?
Model clinical data as coded FHIR R4, expose a FHIR read and export, add the SMART auth surface starting with standalone launch, conform to a profile like US Core where required fields matter, and prove it by running an external SMART client or ONC's Inferno against your endpoint. Interoperability is a property of your data model and auth surface, designed in from the start.
Do I have to run my own FHIR server and OAuth server to be SMART-compliant?
Done from scratch, yes, and it is weeks of undifferentiated plumbing. The shortcut is to build against typed clinical primitives over canonical FHIR R4 and generate the SMART surface, so the discovery document, scopes, PKCE, and tokens come with the box. That is the design intent behind bonfireDB, which is open source and in early access.