SMART on FHIR

Overview

FHIR defines resources and APIs. It does not define who is allowed to call them. That gap is filled by SMART on FHIR — a profiled OAuth 2.0 and OpenID Connect layer built specifically for healthcare app authorization.

Without SMART, every FHIR server invents its own auth mechanism. With it, an app that implements SMART can theoretically launch against any SMART-enabled EHR and get an access token to call FHIR APIs, without bespoke integration work per vendor.

SMART defines two major use cases: app launch (a human user authenticates and authorizes an app to access FHIR data) and backend services (a system-to-system flow with no human user). Both are built on standard OAuth 2.0 grant types with healthcare-specific additions for clinical context — which patient, which encounter, which user.

The current specification is SMART App Launch 2.0 (HL7 ballot standard). v1 is still widely deployed. The differences that matter for implementers are PKCE (now required in v2), granular scopes, and the fhirContext array in token responses.


Discovery

Before doing anything, your app needs to know the authorization endpoint, token endpoint, and what capabilities the server supports. SMART servers publish this at a well-known URL:

GET [fhir-base]/.well-known/smart-configuration

Response:

{
  "issuer": "https://ehr.example.org",
  "authorization_endpoint": "https://auth.ehr.example.org/oauth2/authorize",
  "token_endpoint": "https://auth.ehr.example.org/oauth2/token",
  "token_endpoint_auth_methods_supported": ["private_key_jwt", "client_secret_basic"],
  "grant_types_supported": ["authorization_code", "client_credentials"],
  "scopes_supported": [
    "openid", "profile", "fhirUser", "launch", "launch/patient",
    "patient/*.read", "user/*.read", "offline_access"
  ],
  "response_types_supported": ["code"],
  "capabilities": [
    "launch-ehr",
    "launch-standalone",
    "client-public",
    "client-confidential-symmetric",
    "sso-openid-connect",
    "context-passthrough-banner",
    "context-banner",
    "context-style",
    "context-ehr-patient",
    "context-ehr-encounter",
    "permission-offline",
    "permission-patient",
    "permission-user"
  ],
  "code_challenge_methods_supported": ["S256"]
}

Hardcoding authorization and token endpoints is an anti-pattern. Fetch the well-known configuration at runtime and handle servers that change their endpoint URLs between deployments.


Two Launch Contexts

EHR Launch

The EHR launches your app. The user is already logged into the EHR; the patient and encounter context are already set. The EHR constructs a launch URL for your app including a one-time launch token:

https://your-app.example.org/launch
  ?iss=https%3A%2F%2Fehr.example.org%2Ffhir
  &launch=eyJhbGciOiJSUzI1NiJ9...

Your app reads iss (the FHIR server base URL) and launch (an opaque token), then immediately redirects to the authorization server to begin the OAuth flow. It must include the launch scope and pass the launch token to the authorization request. The EHR’s authorization server decodes the launch token and knows the patient and encounter — the app doesn’t need to ask.

This flow is the primary way clinical apps embedded in EHR portals work. The context (who the clinician is, which patient is open, which encounter is active) is passed automatically.

Standalone Launch

The app launches independently, with no EHR-provided context. The app may know the FHIR server URL from configuration, but it doesn’t know the patient. After authenticating, it must discover context through the token response or by requesting it via scope.

Use launch/patient scope to ask the authorization server to prompt the user to select a patient, and return the patient ID in the token response.


Authorization Code Flow with PKCE

Both launch types use the authorization code flow. In SMART v2, PKCE is required. Here is the full flow:

Step 1: Generate PKCE

Your app generates a cryptographically random code_verifier (43–128 characters, unreserved URL characters), then computes:

code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
code_challenge_method = S256

Store code_verifier in session state. You’ll need it at the token exchange step.

Step 2: Authorization Request

Redirect the user’s browser to the authorization endpoint:

https://auth.ehr.example.org/oauth2/authorize
  ?response_type=code
  &client_id=my-app-client-id
  &redirect_uri=https%3A%2F%2Fyour-app.example.org%2Fcallback
  &scope=launch%20patient%2FObservation.read%20openid%20fhirUser
  &state=af0ifjsldkj
  &aud=https%3A%2F%2Fehr.example.org%2Ffhir
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &launch=eyJhbGciOiJSUzI1NiJ9...

state is a random value your app generates. Validate it on callback to prevent CSRF. aud is the FHIR server URL — this binds the authorization to a specific resource server and prevents token reuse against other servers.

Step 3: Authorization Server Authenticates User

The EHR’s authorization server authenticates the user (if not already authenticated in the EHR launch context), presents a consent screen, and redirects back to your redirect_uri:

https://your-app.example.org/callback
  ?code=SplxlOBeZQQYbYS6WxSbIA
  &state=af0ifjsldkj

Validate state. Discard the code if state doesn’t match.

Step 4: Token Exchange

Exchange the authorization code for tokens:

POST https://auth.ehr.example.org/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fyour-app.example.org%2Fcallback
&client_id=my-app-client-id
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The code_verifier proves your app made the original authorization request. The authorization server verifies the challenge before issuing tokens.

Step 5: Token Response

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "patient/Observation.read openid fhirUser",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk",
  "patient": "Patient/123",
  "encounter": "Encounter/456"
}

patient and encounter are SMART-specific fields outside the OAuth spec. They give you the clinical context. In SMART v2, these may also appear inside a fhirContext array for richer context.


SMART Scopes

Scopes control what FHIR data the access token permits. The scope system has evolved between SMART v1 and v2.

Clinical Scopes

Scope syntaxMeaning
patient/[ResourceType].readRead access to the in-context patient’s resources of this type
patient/[ResourceType].writeWrite access
patient/*.readRead access to all resource types for the in-context patient
user/[ResourceType].readRead access to resources this user can access (may span patients)
system/[ResourceType].readSystem-level access (backend services)

The context level — patient, user, system — is significant. patient scope restricts the token to resources for the patient in the launch context. user scope allows access to whatever resources the authenticated user can see. system scope is for backend services with no user.

SMART v2 Granular Scopes

SMART v2 adds query-string-style filtering to scopes:

patient/Observation.rs?category=laboratory
patient/Observation.rs?code=http://loinc.org|1975-2

r = read, s = search, c = create, u = update, d = delete. The ? filter constrains which resources the scope covers. This allows fine-grained authorization — a scope that only permits reading laboratory Observations, not vital signs.

Granular scopes are v2-only and server support varies. Don’t rely on them if you’re targeting v1 servers.

Launch Scopes

ScopePurpose
launchInclude the opaque launch token from an EHR launch (required for EHR launch)
launch/patientRequest patient selection in standalone launch; receive patient ID in token response
launch/encounterRequest encounter selection in standalone launch

OpenID Connect Scopes

ScopePurpose
openidRequest an id_token (required for OIDC)
fhirUserInclude the FHIR resource URL of the logged-in user in id_token
profileInclude standard OIDC profile claims

fhirUser gives you a claim like "fhirUser": "https://ehr.example.org/fhir/Practitioner/789" in the id_token. You can then read that Practitioner resource to get the user’s name, NPI, and role.


Refresh Tokens

Refresh tokens allow your app to obtain new access tokens after expiry without re-prompting the user. Request them with the offline_access scope. Not all servers grant refresh tokens; some require additional client registration options.

Refresh flow:

POST https://auth.ehr.example.org/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk
&client_id=my-app-client-id

Implement refresh proactively — check expires_in against the current time before each API call, and refresh if the token has less than 60 seconds remaining. Do not let tokens expire mid-operation.


Backend Services (System-to-System)

Backend services don’t involve a human user. They use the client_credentials grant with a JWT client assertion signed by the app’s private key — there is no authorization code flow, no user redirect, no consent screen.

Registration

Register your app’s public key (or JWKS endpoint) with the EHR/payer out-of-band. This is a pre-deployment step.

Token Request

POST https://auth.ehr.example.org/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
&scope=system%2FPatient.read%20system%2FObservation.read

The client_assertion JWT must include:

  • iss: your client ID
  • sub: your client ID
  • aud: the token endpoint URL
  • jti: a unique ID per request (prevent replay)
  • exp: expiry (short — 5 minutes is typical)

The JWT is signed with your private key. The authorization server verifies the signature against your registered public key.

Response

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "system/Patient.read system/Observation.read"
}

No patient context in backend service tokens — the system has access to all resources its granted scopes cover. Use this flow for bulk data export, payer-to-payer data exchange, and provider access APIs under CMS interoperability rules.


Token Validation

When your FHIR server receives a token, it validates it before processing the request. The validation approach depends on token format:

Opaque tokens: Server calls the authorization server’s introspection endpoint:

POST [auth-server]/introspect
Authorization: Basic [server-credentials]

token=eyJhbGciOiJSUzI1NiJ9...

JWT tokens: Server validates the signature against the authorization server’s JWKS, checks exp, aud, and iss claims, then extracts scope from the token claims directly. No introspection call needed.

Your app should also validate the id_token JWT before trusting identity claims from it — verify signature, expiry, and aud matches your client ID.


Threat Model

SMART protects against:

  • Unauthorized API access: Without a valid token, FHIR API calls are rejected.
  • Token interception: PKCE prevents authorization code interception attacks. Short token expiry limits the window if an access token leaks.
  • CSRF: The state parameter prevents cross-site request forgery in the redirect callback.

SMART does not protect against:

  • Overpermissioned scopes: If an app requests and receives patient/*.read, it can access all of the patient’s data. Whether it uses all of it is up to the app. The authorization server grants scopes; it doesn’t audit what the app does with the data.
  • Data exfiltration within granted scopes: Once the app has a valid token, what it sends data to is not SMART’s concern.
  • Malicious apps: SMART is authorization infrastructure, not app trust infrastructure. A malicious but SMART-compliant app gets a token like any other.

Common Failure Modes

Scope mismatch between request and grant. The authorization server may grant a subset of requested scopes. Always parse the scope field in the token response — don’t assume you got what you asked for. Design your app to degrade gracefully when scopes are narrower than requested.

PKCE not implemented or not required. Older servers may not support S256 and may reject the code_challenge parameter. Newer servers may require PKCE and reject auth requests without it. Test against your target servers; don’t implement PKCE as an afterthought.

EHR launch: missing launch scope. In EHR launch, you must include launch in the scope and pass the launch parameter from the launch URL. Without it, the authorization server has no way to associate your request with the EHR session and won’t populate patient/encounter context.

Standalone launch: no patient selection. If you omit launch/patient in standalone launch against a server that requires it, the token response won’t include a patient ID. Your app must handle the missing patient context gracefully or re-authorize with the correct scope.

Not handling token expiry. Access tokens expire. If your app caches a token and doesn’t check expires_in, it will start getting 401s after an hour. Implement proactive refresh or at minimum retry-with-refresh on 401 responses.

id_token not validated. Accepting identity claims from an id_token without verifying its signature means a compromised or spoofed token response could inject false identity data. Always validate JWT signatures against the authorization server’s JWKS.

Hardcoded authorization endpoints. When EHR vendors update their authorization infrastructure, hardcoded endpoints break. Always fetch /.well-known/smart-configuration at launch time.

Section: fhir Content Type: reference Audience: technical
FHIR Versions:
R4 R5
Published: 07/08/2023 Modified: 29/10/2025 14 min read
Keywords: SMART on FHIR FHIR authorization OAuth2 FHIR EHR launch SMART scopes PKCE backend services FHIR app launch