This guide covers what the README Quick Start doesn't:
running smart-launch in a real application, against real EHR
vendors, in production. Everything here reflects either the library's
actual implemented behavior or launch flows verified live against public
sandboxes (SMART Health IT, Epic, Cerner/Oracle Health) during
development — not assumptions about how vendors are "supposed to" behave.
It implements: discovery, PKCE (S256), the EHR and standalone launch redirects, authorization-code-for-token exchange, refresh, and typed launch-context parsing.
It deliberately does not ship: a production token store, a session
store, retry/backoff policies, or telemetry. Those are your application's
responsibility — the library gives you the interfaces (TokenStorage,
the PendingLaunch shape) and gets out of the way.
InMemoryTokenStorage (src/storage.ts) exists for the examples and
tests only — tokens vanish on process restart and aren't shared across
instances. For production, implement TokenStorage against whatever
you already run:
import type { TokenStorage, StoredToken } from "@peerbits/smart-launch";
class RedisTokenStorage implements TokenStorage {
constructor(private redis: RedisClient) {}
async get(key: string): Promise<StoredToken | undefined> {
const raw = await this.redis.get(`smart-token:${key}`);
return raw ? JSON.parse(raw) : undefined;
}
async set(key: string, token: StoredToken): Promise<void> {
// Expire slightly after the token itself so a late refresh
// attempt still finds the refresh_token.
const ttlSeconds = token.expiresAt
? Math.ceil((token.expiresAt - Date.now()) / 1000) + 3600
: undefined;
await this.redis.set(`smart-token:${key}`, JSON.stringify(token), { EX: ttlSeconds });
}
async delete(key: string): Promise<void> {
await this.redis.del(`smart-token:${key}`);
}
}Whatever backend you choose (Redis, Postgres, an encrypted session
column): encrypt at rest if the store is shared infrastructure —
access_token/refresh_token are bearer credentials to the patient's
or provider's clinical data. This library does not encrypt them for
you; the interface just moves bytes.
buildEhrAuthorizationRequest/buildStandaloneAuthorizationRequest
return a pending: PendingLaunch object (state, codeVerifier, iss,
tokenEndpoint, redirectUri, optional launch) that your app must
persist somewhere between the redirect and the callback, then hand back
to handleEhrCallback/handleStandaloneCallback.
The example apps (docs/examples/*/server.ts) use a plain in-process
Map keyed by state — fine for a demo, wrong for production:
- It doesn't survive a process restart or a second instance behind a load balancer.
- Nothing evicts stale entries — an abandoned launch leaks memory forever.
In production, use a real session store (signed cookie session,
Redis-backed session, etc.) scoped to the user's browser session, with
a short TTL (a few minutes — a launch that hasn't completed by then
should be considered abandoned). Do not put PendingLaunch in a
database table keyed by something guessable; state is already your
CSRF token, treat it with the same care as a CSRF token anywhere else
in your app.
Every error the library throws extends SmartLaunchError. Catch the
specific subclass, not the message string:
| Error | Thrown when | Recommended handling |
|---|---|---|
MissingLaunchParameterError |
Required param (iss, launch, code) missing before a redirect or during callback |
400 to the user; this fires before any redirect, so nothing has leaked |
DiscoveryError |
Both .well-known/smart-configuration and the CapabilityStatement fallback failed |
Surface a "can't reach this FHIR server" error; log the cause field for diagnostics |
StateMismatchError |
Callback state doesn't match the persisted pending.state |
Never proceed to token exchange (the library already refuses to) — treat as a potential CSRF attempt, log it, show a generic error |
TokenExchangeError |
Token endpoint returned an OAuth2 error (error/error_description fields populated) |
Branch on .error (e.g. invalid_grant, access_denied) rather than parsing .message |
RefreshFailedError |
No token stored for the key, or refresh_token was rejected/expired | Re-trigger the launch flow from scratch — there's no recovery path other than a fresh authorization |
PkceValidationError |
Exposed for anyone building a mock/test authorization server; the library itself never fails PKCE validation locally (that check happens server-side, surfacing as TokenExchangeError) |
N/A in normal client usage |
Don't call refreshAccessToken on a timer yourself — use
ensureFreshToken, which checks expiry (with a configurable skew) and
only calls the token endpoint when actually needed:
const token = await ensureFreshToken(config, pending.tokenEndpoint, tokenStorage, sessionKey, {
onRefresh: async (refreshed) => {
// Called only when a refresh actually happened — persist,
// emit metrics, whatever your app needs.
await auditLog.record("token_refreshed", { sessionKey });
},
});Call this at the start of every request that needs to call the FHIR API, not on a background timer — it's cheap when the token is still fresh (no network call at all) and correct when it isn't.
redirect_urimust be HTTPS in production (localhost HTTP is fine for local dev only — every vendor sandbox we tested enforces an exact string match against what's registered, scheme included).- PKCE is always S256 — this library never implements
plain, and there's no config flag to downgrade it. stateis validated before token exchange, always — this isn't optional or bypassable through configuration.- No default client credentials —
LaunchConfig.clientIdhas no fallback in the core library (only the example apps default toYOUR_CLIENT_ID_HEREfor local testing). Production config must come from your secret manager, never hardcoded. - Confidential clients: this library only supports a plain
client_secretin the token request body (config.clientSecret). It does not implementprivate_key_jwt/ JWT-assertion client authentication. If your vendor registration requires that (Epic's "confidential client" + JWK Set configuration does), this library cannot complete that token exchange as-is — that's a real gap, not a configuration issue, and would need new code intoken.tsto support.
There is no environment: "sandbox" | "production" flag anywhere in
this library, and that's intentional, not an oversight. Per FR5 in the
build spec, LaunchConfig is fully caller-supplied with no defaults
that resemble a real value — the library has no concept of "environment"
to begin with. Discovery (discover()) just hits whatever iss you
give it; nothing about it is sandbox-aware or production-aware.
Sandbox and production are just two different LaunchConfig values
for the same vendor — a different iss (FHIR base URL), a different
client_id, and usually a different client_secret. This is how every
vendor we tested actually models it: Epic's own app console has
separate "Client ID" (production) and "Non-Production Client ID"
(sandbox) fields with independent secrets; Cerner and SMART Health IT
follow the same pattern of environment-scoped credentials tied to
environment-scoped FHIR base URLs.
The right place to manage that split is your application's own config loading, not this library:
// Pick config by deployment environment — plain application code,
// nothing SMART-specific about this part.
const config: LaunchConfig =
process.env.DEPLOY_ENV === "production"
? {
clientId: await secrets.get("epic-prod-client-id"),
clientSecret: await secrets.get("epic-prod-client-secret"),
iss: "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
redirectUri: "https://app.example.com/callback",
scopes: ["openid", "fhirUser", "patient/Patient.rs"],
}
: {
clientId: await secrets.get("epic-sandbox-client-id"),
iss: "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4", // same sandbox base for most vendors
redirectUri: "http://localhost:3000/callback",
scopes: ["openid", "fhirUser"],
};Adding an internal environment flag to this library would duplicate
what iss/clientId/clientSecret already express, and would work
against FR5's explicit "no assumptions baked in" intent — there's
nothing an environment flag would let the library do that supplying a
different config value doesn't already do.
This section covers the three vendors validated hands-on during development. For the full per-vendor breakdown (supported launch types, required scopes, known limitations) including four additional vendors sourced from public docs, see vendor-notes.md.
SMART Health IT (launch.smarthealthit.org) is the easiest to
validate against and is spec-compliant by design. One quirk: its
"standalone" test mode still expects a launch context parameter
internally — hitting its /authorize endpoint directly with a
spec-correct, launch-param-free standalone request can fail with
Invalid launch options: SyntaxError: Unexpected end of JSON input.
Use their own picker UI (which drives the EHR-launch path, not
standalone) to test against this sandbox specifically.
Epic: scope syntax and client-type settings are unforgiving.
Confirm your app's registered SMART Scope Version (v1 vs v2) before
picking a scope string — v1's patient/*.read wildcard is rejected
outright by a v2-registered app. redirect_uri must be registered
exactly, including port — Epic's own "Invalid OAuth 2.0 request" error
page gives no detail on which check failed, so verify config
methodically rather than guessing from the error text alone. Also check
Epic's Data Use Questionnaire on the app registration — an
incomplete questionnaire can block sandbox activation even when every
OAuth2 parameter is correct.
Cerner/Oracle Health: keep Redirect URI (where the code comes
back) and SMART® Launch URI (where the EHR opens your app) as two
distinct, correctly-suffixed URLs — pointing both at the same path
breaks the callback step. If you see
urn:cerner:error:authorization-server:smart-v1:grant:launch:mismatched-identity,
try the test flow in an incognito/private window — an existing
developer-console session in the same browser can conflict with the
simulated patient identity the test launch expects.
General pattern across all three: none of the real failures we hit during validation were library bugs — they were vendor console configuration (redirect URI registration, scope version, client type) or vendor-side session state. Discovery, PKCE, state validation, and token exchange worked correctly against all three on the first attempt each time the configuration matched what the library actually sends.
See common-errors.md for the fuller writeup of each of these (causes, fixes, and which typed error class each one surfaces as).
| Symptom | Likely cause | Fix |
|---|---|---|
| Generic "Invalid OAuth 2.0 request" (Epic) with no other detail | redirect_uri not registered exactly, or scope-version mismatch, or an incomplete app questionnaire |
Check redirect URI list first (most common), then scope version, then any pending registration questionnaire |
mismatched-identity (Cerner) |
Existing developer session in the same browser | Retry in incognito/private window |
Invalid launch options: SyntaxError... (SMART Health IT) |
Hit /authorize directly for "standalone" without going through their picker |
Use their picker UI, or test standalone against a different vendor |
| Scope string in your outgoing request doesn't match what you configured | Vendor may grant its own pre-approved scope set regardless of what you request (observed with Epic) | Read the actual scope field back from the token response (token.scope) rather than assuming what you requested is what you got |
StateMismatchError immediately on callback |
Multiple tabs/retries reusing a stale pending, or the session store lost the entry |
Ensure PendingLaunch persistence survives the redirect round-trip (see §3) |
- Production
TokenStorageimplemented and encrypted at rest -
PendingLaunchpersisted in a real session store with a short TTL, not an in-processMap - All
redirect_urivalues are HTTPS and registered exactly with every vendor you support -
clientId/clientSecretsourced from a secret manager, never committed, and scoped per environment (see §7) -
ensureFreshTokencalled per-request rather than a background refresh timer - Error branches for every
SmartLaunchErrorsubclass in §4, not just a generic catch-all - If any target vendor requires confidential/JWT-assertion client auth, confirmed this library's plain-secret-only token exchange is sufficient (see §6) before committing to that vendor