smart-on-fhir — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited smart-on-fhir (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert in SMART on FHIR — the OAuth 2.0 profile that lets third-party apps and services securely access FHIR APIs in EHRs. Your goal is to help engineers implement a correct, secure SMART flow end-to-end, with the right scopes, the right client type, and the right launch model for the use case.
Read .agents/healthcare-context.md first (fall back to .claude/healthcare-context.md). The context file tells you which EHR(s) you are targeting (each one publishes slightly different SMART support), which FHIR version, and whether the app is patient-facing, clinician-facing, or system-to-system.
If the context file does not exist, ask:
| Model | Who starts | When to use |
|---|---|---|
| EHR launch | Clinician clicks the app from within the EHR chart | Clinician-facing apps, ambient scribes, contextual tools |
| Standalone launch | User opens the app first, then signs in | Patient-facing apps, cross-institution tools |
| Backend Services | No user — system-to-system | Bulk data pipelines, analytics, automated jobs |
You can support multiple models in the same app — register separately for each.
1. EHR opens the app URL with a `launch` token + `iss` (FHIR base URL).
2. App fetches `<iss>/.well-known/smart-configuration` to discover endpoints.
3. App redirects user agent to `authorization_endpoint` with:
response_type=code, client_id, redirect_uri, scope, state,
aud=<iss>, launch=<launch_token>, code_challenge, code_challenge_method=S256
4. EHR authenticates the user, optionally prompts for consent.
5. EHR redirects back to app's redirect_uri with `code` and `state`.
6. App POSTs to `token_endpoint` with code, redirect_uri, client_id, code_verifier
(and client_secret or JWT assertion for confidential clients).
7. EHR returns access_token + (optionally) refresh_token + id_token + context:
{ access_token, token_type: "Bearer", expires_in, scope, patient, encounter, ... }
8. App calls FHIR APIs with `Authorization: Bearer <access_token>`.state is mandatory for CSRF protection. PKCE (S256) is required for public clients and strongly recommended for confidential clients.
In standalone launch, the user starts in the app. The app initiates step 3 directly with no launch parameter; it requests launch/patient scope so the EHR prompts the user to select a patient (typically the user is the patient — a patient portal flow).
Every SMART-enabled FHIR server should publish:
GET https://ehr.example.org/fhir/R4/.well-known/smart-configurationResponse (selected fields):
{
"authorization_endpoint": "https://ehr.example.org/oauth2/authorize",
"token_endpoint": "https://ehr.example.org/oauth2/token",
"introspection_endpoint": "https://ehr.example.org/oauth2/introspect",
"revocation_endpoint": "https://ehr.example.org/oauth2/revoke",
"scopes_supported": [
"openid", "fhirUser", "launch", "launch/patient", "launch/encounter",
"patient/*.read", "user/*.read", "offline_access", "online_access",
"system/*.read"
],
"response_types_supported": ["code"],
"capabilities": [
"launch-ehr", "launch-standalone",
"client-public", "client-confidential-symmetric", "client-confidential-asymmetric",
"context-ehr-patient", "context-ehr-encounter",
"permission-patient", "permission-user", "permission-online", "permission-offline",
"sso-openid-connect",
"permission-v2"
],
"code_challenge_methods_supported": ["S256"]
}Confirm the EHR's actual response — capabilities vary.
| Scope | Means |
|---|---|
launch | The app is being launched from an EHR; expect a launch token |
launch/patient | Standalone launch; prompt user to pick a patient |
launch/encounter | Standalone launch; prompt user to pick an encounter |
patient/Observation.read | Read Observations for the patient in context |
patient/*.read | Read every resource type for the patient in context |
user/Practitioner.read | Read Practitioner resources the user can see |
user/*.write | Write any resource the user can write |
system/*.read | System-to-system read (Backend Services) |
openid fhirUser | OIDC: get an id_token; fhirUser claim is a FHIR reference to the user |
online_access | Allow refresh token only while user has an active session |
offline_access | Allow long-lived refresh token even when user is offline |
SMART v2 adds resource-level granularity and search parameter restriction:
| Scope | Means |
|---|---|
patient/Observation.rs | Read + search for Observations in patient context |
patient/Observation.rs?category=laboratory | Read + search only labs |
user/MedicationRequest.cruds | Full CRUD + search on MedicationRequest |
user/Patient.r | Read only (not search) |
v2 actions: c (create), r (read), u (update), d (delete), s (search). Constrain to the minimum needed.
patient/ scopes for patient-facing apps; user/ for clinician apps; system/ for backend.offline_access only if you actually need long-lived background sync.permission-v2 capability), use v2 granular scopes.| Client type | Where it runs | Can keep a secret? | Auth method |
|---|---|---|---|
| Public | Browser SPA, mobile app | No | PKCE only |
| Confidential symmetric | Server-side web app | Yes | client_secret + PKCE |
| Confidential asymmetric | Server-side, high-assurance | Yes (private key) | JWT client assertion (RFC 7523) |
For SMART Backend Services, confidential asymmetric (JWT assertion with a JWKS-published public key) is the only standard option.
PKCE is mandatory for public clients and best-practice for confidential clients.
code_verifier = random 43-128 char string (base64url, unreserved)
code_challenge = base64url(SHA256(code_verifier))
code_challenge_method = "S256"Send code_challenge + code_challenge_method on the authorize call; send code_verifier on the token call. The authorization server verifies.
Per RFC 7662. The resource server (your app, or another component) asks the auth server whether a bearer token is currently valid:
POST /oauth2/introspect
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
token=<access_token>Response:
{
"active": true,
"scope": "patient/*.read launch/patient",
"client_id": "example-app",
"sub": "Practitioner/example-prov-1",
"exp": 1737200000,
"patient": "example-1"
}Not every EHR exposes introspection. For most apps, just validate the token signature and exp claim locally if it's a JWT, or simply rely on the FHIR API to reject expired tokens.
After token exchange, the response includes launch context for use in API calls:
| Field | Means |
|---|---|
patient | Patient ID for context — use as ?patient=<id> in FHIR calls |
encounter | Encounter ID for context |
fhirUser (id_token claim, if openid fhirUser) | FHIR reference to the user (e.g., Practitioner/example-prov-1) |
tenant (vendor-specific) | Tenant/customer identifier |
need_patient_banner (Epic / some vendors) | UI hint — does the EHR already display a patient banner? |
Persist this context across the session, not across sessions — a new launch may select a different patient.
For system-to-system, no-user scenarios (bulk export, automated quality reporting, registry submission).
{
"iss": "<your-client-id>",
"sub": "<your-client-id>",
"aud": "https://ehr.example.org/oauth2/token",
"exp": <now + 5 minutes>,
"jti": "<random unique>"
}Sign with your private key (RS384 or ES384).
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=system/*.read
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed_jwt>Use Backend Services for Bulk Data Access ($export) — kick off the export, poll the status endpoint, download NDJSON.
https://app.example.com/launch?iss=https://ehr.example.org/fhir/R4&launch=abc123GET https://ehr.example.org/fhir/R4/.well-known/smart-configurationGET https://ehr.example.org/oauth2/authorize
?response_type=code
&client_id=example-app
&redirect_uri=https://app.example.com/cb
&scope=launch openid fhirUser patient/*.read offline_access
&state=opaque-csrf-token
&aud=https://ehr.example.org/fhir/R4
&launch=abc123
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256https://app.example.com/cb?code=xyz789&state=opaque-csrf-tokenVerify state matches what you sent. Reject if not.
POST https://ehr.example.org/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=xyz789
&redirect_uri=https://app.example.com/cb
&client_id=example-app
&code_verifier=<original_verifier>(For confidential clients, add client_secret or use client_assertion instead.)
GET https://ehr.example.org/fhir/R4/Patient/example-1
Authorization: Bearer <access_token>
Accept: application/fhir+jsonPOST https://ehr.example.org/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<refresh_token>
&scope=launch openid fhirUser patient/*.read offline_accessNote that some EHRs rotate refresh tokens on each use — store the latest one returned.
SMART apps and CDS Hooks complement each other:
link of type: smart opens a SMART app — the EHR launches it as an EHR launch with context already established.fhirAuthorization field gives the service a short-lived token to fetch additional data — same OAuth, smaller scope.state on every redirectiss and aud on every token / id_tokencode parametersAuditEvent or your own audit log)aud in the authorize URL — required by SMART; EHRs will reject.client_secret for a public SPA. SPAs cannot keep a secret; use PKCE alone.*.write scopes you don't actually use. Users (and approvers) will reject the app.iss is different.iss URL (sandbox + each prod tenant)?link.type=smart.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.