What is SMART-on-FHIR? EHR launch, scopes, PKCE & conformance

SMART-on-FHIR is how a health app proves who's using it and reads or writes clinical data with permission — the closest thing to "Sign in with the EHR." Here's the whole flow end to end: the two launches, the discovery document, v1 vs v2 scopes, PKCE, tokens and refresh, conformance with Inferno, the pitfalls that bite everyone, and the honest playbook for shipping one.

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.

  1. The EHR launches your app with a launch token and an iss. It opens your registered launch URL with two query params: iss (the base URL of the EHR's FHIR server) and an opaque launch string that carries the in-EHR context.
  2. Discover the auth endpoints. Take that iss and fetch {iss}/.well-known/smart-configuration to learn the authorization and token endpoints (more on this document below). Never hardcode these — they differ per EHR and per tenant.
  3. Redirect to authorize with the launch context + PKCE. Send the user to the authorization endpoint with your client_id, redirect_uri, the launch token, your requested scope, a random state, a PKCE code_challenge — and, critically, the aud parameter set to that same iss FHIR base URL.
  4. 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_uri with an authorization code and the original state.
  5. Exchange the code for tokens. POST the code, your redirect_uri, and the PKCE code_verifier to the token endpoint. Back comes an access_token, an id_token, optionally a refresh_token, and the launch context fields.
  6. Read the patient context off the token response. The token response includes context like patient (the FHIR id you were launched on) and the granted scope. You did not have to ask the user who they're looking at — the EHR told you.
  7. Call FHIR. Use the access_token as 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.

  1. You already know the FHIR base (iss). It's your own server, or a server the user picked. Fetch its /.well-known/smart-configuration the same way.
  2. Redirect to authorize — with launch/patient, not a launch token. Because there's no EHR context to inherit, you request the launch/patient scope, which tells the authorization server "select a patient for me as part of login." Include aud, state, and PKCE exactly as before.
  3. 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.
  4. Code → token → context → FHIR. Identical to EHR launch from here: exchange the code (with the PKCE verifier), read the patient context 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. So patient/Observation.rs means "read + search this patient's Observations" and nothing else; patient/Observation.cruds is full access. This is what lets you actually express minimum-necessary.
IntentSMART v1SMART v2
Read + search a patient's labspatient/Observation.readpatient/Observation.rs
Record new observations onlypatient/Observation.write (also allows update/delete)patient/Observation.c
Full CRUD on a resourcepatient/Observation.*patient/Observation.cruds
Read everything in scopepatient/*.readpatient/*.rs

Alongside the resource scopes are the non-resource scopes that carry identity and lifecycle, not data permissions:

  • openid + fhirUser — return an OpenID Connect id_token identifying the logged-in user as a FHIR resource (a Practitioner or Patient). This is the "who is this person" half of SMART.
  • launch — request the EHR launch context (used in EHR launch with the inbound launch token).
  • launch/patient — request a patient context in standalone launch (the picker / self-resolution step above).
  • offline_access — ask for a refresh_token so 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:

  1. Before redirecting, your app generates a random, high-entropy code_verifier.
  2. It hashes that with SHA-256 to produce a code_challenge, and sends the challenge (plus code_challenge_method=S256) to the authorize endpoint.
  3. 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 for openid fhirUser. A signed OpenID Connect token identifying the user; decode it to learn who logged in (their fhirUser reference).
  • refresh_token — only if you asked for offline_access. A long-lived token you exchange at the token endpoint for a fresh access_token when 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.

Inferno and (g)(10) are the external bar you aim at to prove conformance — not a certification bonfireDB claims to hold. The honest framing: build to the standard, test against the standard's own suite, and let the results speak. bonfireDB is the open alternative that generates the FHIR + SMART surface; conformance is something you verify, not something that ships pre-stamped.

Common pitfalls

  • aud wrong or missing. The single most frequent failure. aud must equal the EHR's FHIR base URL (the same value as iss). Conformant servers reject mismatches to prevent token replay.
  • Requesting v1 scopes from a v2 server (or vice versa). Check capabilities in the discovery document and request the version the server advertises; don't assume .read works 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.
  • state mismatch. Generate a random state, store it, and verify it on the callback. Skipping it leaves you open to CSRF on the redirect.
  • Treating launch/patient context as automatic in EHR launch. You still must request the launch scope and read the patient field off the token response — the context isn't injected into your session for free.
  • Insecure token storage. Don't park access/refresh tokens in localStorage or 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

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 inbound launch token). Most builders need standalone first.
  • The flow: discover at /.well-known/smart-configuration → authorize with aud + PKCE → code → token → context → call FHIR. Getting aud/iss wrong is the #1 mistake.
  • Scopes: v1 .read/.write vs v2 granular .rs/.cruds; plus openid/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.
FAQ

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.

Ship a SMART-on-FHIR app — not a FHIR server.

bonfireDB generates FHIR R4 and a standalone SMART surface from typed clinical primitives. Open source, early access.