DEV Community

mock health
mock health

Posted on • Originally published at mock.health

Build a SMART on FHIR App in 30 Minutes

SMART on FHIR is how apps talk to electronic health records. It's OAuth 2.0 with a few healthcare-specific conventions: a discovery document that tells your app where to authenticate, scopes that map to FHIR resource types, and a launch context that tells you which patient you're looking at.

This tutorial follows SMART App Launch STU2.1 — the current published standard. If you've built an OAuth login flow before, you already understand 80% of it. The other 20% is what this covers.

We're going to build a standalone SMART app that authenticates against a FHIR server, fetches a patient's vital signs, and renders interactive charts — heart rate, blood pressure, SpO2, temperature, weight, and BMI. The whole thing fits in a single index.html file. No React, no bundler, no npm install. Just a text editor and a browser.

By the end you'll have a working app and a mental model for how every SMART on FHIR app works under the hood — whether it's a one-page demo or a production clinical decision support tool launching inside Epic.

Prerequisites

You need two things:

1. A mock.health account. Go to mock.health and sign up. It's free. This gives you a FHIR R4 server with synthetic patients that actually have realistic clinical data — vital signs, conditions, medications, imaging studies.

2. A registered SMART app. In the mock.health dashboard, go to Sandbox → Register App and create a new app with these settings:

  • App Name: Vital Signs Chart (or whatever you want)
  • Redirect URI: http://localhost:8080/smart-on-fhir-vital-signs/index.html
  • Scopes: launch/patient, patient/Patient.rs, patient/Observation.rs, openid, fhirUser

Copy the Client ID — you'll need it in a minute.

A note on scope syntax. SMART App Launch STU2 introduced a new scope format. The v1 format patient/Patient.read became patient/Patient.rs in v2, where the suffix letters map to individual operations: create, read, update, delete, search. So .rs means read + search, and v1's .write maps to v2's .cud. Servers advertise which format they support via permission-v1 and permission-v2 in their capabilities. Most production EHRs accept both formats today. This tutorial uses v2 (*.rs), but v1 (*.read) will also work with mock.health.

The redirect URI matters. It must exactly match the URL where your app is running. When the authorization server sends the user back to your app after login, it appends an authorization code to this URL. If it doesn't match what you registered, the server rejects the request.

Step 1: Discover the SMART Endpoints

Every SMART-enabled FHIR server publishes a discovery document at /.well-known/smart-configuration. This tells your app where to send the user for authorization and where to exchange codes for tokens.

const baseUrl = 'https://api.mock.health';

const response = await fetch(`${baseUrl}/fhir/.well-known/smart-configuration`);
const config = await response.json();

// config.authorization_endpoint → where to redirect the user
// config.token_endpoint         → where to exchange the code for a token
Enter fullscreen mode Exit fullscreen mode

The response looks like this:

{
  "issuer": "https://api.mock.health",
  "authorization_endpoint": "https://api.mock.health/smart/authorize",
  "token_endpoint": "https://api.mock.health/smart/token",
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["launch/patient", "patient/Patient.rs", "patient/Observation.rs", "openid", "fhirUser"],
  "capabilities": ["launch-standalone", "client-public", "context-standalone-patient", "permission-v1", "permission-v2", "sso-openid-connect"]
}
Enter fullscreen mode Exit fullscreen mode

Why discovery instead of hardcoding? Because every FHIR server puts these endpoints at different paths. Epic uses /oauth2/authorize. Oracle Health (Cerner) uses /tenants/{tenant}/protocols/oauth2/profiles/smart-v1/personas/patient/authorize. The discovery document means your app works against any conformant server without code changes.

Step 2: Generate a PKCE Challenge

PKCE (Proof Key for Code Exchange, pronounced "pixie") prevents authorization code interception attacks. Your app generates a random secret (the verifier), hashes it (the challenge), and sends only the hash to the authorization server. When you exchange the code for a token later, you prove you're the same app by sending the original verifier.

function generateCodeVerifier(len = 64) {
  const arr = new Uint8Array(len);
  crypto.getRandomValues(arr);
  return base64url(arr);
}

async function computeCodeChallenge(verifier) {
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier)
  );
  return base64url(new Uint8Array(digest));
}

function base64url(bytes) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
Enter fullscreen mode Exit fullscreen mode

Store the verifier in sessionStorage — you'll need it after the redirect.

const verifier = generateCodeVerifier();
const challenge = await computeCodeChallenge(verifier);
sessionStorage.setItem('pkce_verifier', verifier);
Enter fullscreen mode Exit fullscreen mode

Step 3: Redirect to Authorize

Build the authorization URL and redirect the user's browser:

const state = crypto.randomUUID();
sessionStorage.setItem('auth_state', state);

const params = new URLSearchParams({
  response_type: 'code',
  client_id: clientId,
  redirect_uri: location.origin + location.pathname,
  scope: 'launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser',
  state,
  aud: `${baseUrl}/fhir`,
  code_challenge: challenge,
  code_challenge_method: 'S256',
});

location.href = `${config.authorization_endpoint}?${params}`;
Enter fullscreen mode Exit fullscreen mode

What each parameter does:

  • response_type: 'code' — We want an authorization code. Always code.
  • client_id — Identifies your app.
  • redirect_uri — Where to send the user back. Must exactly match what you registered.
  • scope — What data you're requesting. launch/patient asks for a patient context. patient/Observation.rs asks to read and search Observations scoped to that patient.
  • state — A random string to prevent CSRF attacks.
  • aud — The FHIR endpoint this token should work with. Prevents token replay across servers.
  • code_challenge — The PKCE challenge.

After this redirect, the user sees a consent screen. When they approve, the server redirects back to your redirect_uri with ?code=...&state=... appended.

Step 4: Handle the Callback and Exchange the Code

When your page loads, check if there's a code parameter in the URL:

const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');

if (code) {
  if (state !== sessionStorage.getItem('auth_state')) {
    throw new Error('State mismatch — possible CSRF');
  }

  history.replaceState(null, '', location.pathname);

  const verifier = sessionStorage.getItem('pkce_verifier');

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: location.origin + location.pathname,
    client_id: clientId,
  });
  if (verifier) body.set('code_verifier', verifier);

  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  const tokens = await response.json();
}
Enter fullscreen mode Exit fullscreen mode

The token response includes everything you need:

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser",
  "patient": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "id_token": "eyJhbGciOiJSUzI1NiJ9..."
}
Enter fullscreen mode Exit fullscreen mode

Two SMART-specific fields: patient is the launch context — the ID of the patient the user selected. id_token contains the authenticated user's identity via the fhirUser claim.

Step 5: Fetch Vital Signs

Now you have a bearer token and a patient ID:

const headers = {
  Authorization: `Bearer ${tokens.access_token}`,
  Accept: 'application/fhir+json',
};

const patient = await fetch(`${baseUrl}/fhir/Patient/${tokens.patient}`, { headers })
  .then(r => r.json());

const observations = await fetch(
  `${baseUrl}/fhir/Observation?patient=${tokens.patient}&category=vital-signs&_sort=-date&_count=200`,
  { headers }
).then(r => r.json());
Enter fullscreen mode Exit fullscreen mode

Each Observation has a LOINC code that identifies what it measures:

Vital Sign LOINC Code Value Location
Heart Rate 8867-4 valueQuantity.value
Blood Pressure 85354-9 component[].valueQuantity.value
Temperature 8310-5 valueQuantity.value
SpO2 2708-6 valueQuantity.value
Weight 29463-7 valueQuantity.value
BMI 39156-5 valueQuantity.value

Blood pressure is the odd one out. It's a panel — the Observation itself has LOINC code 85354-9, but the actual systolic and diastolic values live in component entries with their own LOINC codes (8480-6 for systolic, 8462-4 for diastolic). Every FHIR developer hits this for the first time and wonders why it's nested. Welcome to the club.

Step 6: Render the Charts

We use Chart.js — one CDN script tag, no build step:

<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
Enter fullscreen mode Exit fullscreen mode

Group observations by type, sort by date, render a line chart for each. For blood pressure, render two datasets on the same chart — systolic and diastolic.

The Complete App

The full working app is ~475 lines in a single index.html. Clone it and run it:

git clone https://github.com/mock-health/samples.git
cd samples
python3 -m http.server 8080

# Open http://localhost:8080/smart-on-fhir-vital-signs/index.html
Enter fullscreen mode Exit fullscreen mode

Enter your Client ID, click Connect, approve access, and you'll see your patient's vital signs charted out.

The full source is on GitHub.

What Changes in Production EHRs

This tutorial uses mock.health, which implements the SMART spec faithfully. Production EHRs are different:

App registration takes weeks, not seconds. Epic's App Orchard requires a formal review process. Budget 2–8 weeks for approval.

Scopes vary. Some EHRs still only accept v1 syntax (patient/Observation.read). Check the server's .well-known/smart-configuration capabilities.

Discovery endpoints aren't always where you expect. Some older servers don't support .well-known/smart-configuration. Fall back to the CapabilityStatement (GET /metadata) and extract OAuth URLs from rest[0].security.extension.

Tokens expire and refresh tokens matter. In production, request offline_access scope and implement refresh token rotation.

None of this changes the fundamental flow. Discovery → PKCE → authorize → callback → token → API calls. Same everywhere. The operational details differ.


Sign up at mock.health — free tier, no sales call. The hardest part of building a SMART on FHIR app is getting access to a server that has realistic data and implements the spec correctly. Now you have one.

Top comments (0)