# SMART on FHIR app launch: EHR launch vs standalone, iss, aud and PKCE

> Implement SMART App Launch 2.2.0 against Epic and other EHRs — EHR launch versus standalone, iss and aud, the launch token, PKCE S256, and why the Epic sandbox is not production.

Author: Ala Ben Aicha

Canonical page: https://alabenaicha.me/insights/smart-on-fhir-app-launch

Updated: 2026-09-17

## Direct answer

EHR launch passes iss and an opaque launch token; standalone does not. Authorize with aud equal to the FHIR base and PKCE S256. Treat Epic sandbox versus production as a test matrix, not an SLA.

## Launch-mode mapping

Pin [SMART App Launch 2.2.0](https://hl7.org/fhir/smart-app-launch/app-launch.html) (STU 2.2, package `hl7.fhir.uv.smart-app-launch#2.2.0`, generated 30 April 2024, on FHIR R4). The IG supports four use cases: patient standalone, patient portal launch, provider standalone, provider portal launch. This page is the launch and token handshake, not a resource-by-resource FHIR tutorial — that remains the [FHIR integration guide](https://alabenaicha.me/insights/hl7-fhir-integration-guide). Vendor flavour versus Cerner/Oracle Health is the [Epic vs Cerner comparison](https://alabenaicha.me/insights/epic-vs-cerner-comparison).

| Mode                  | How the app starts                                                        | Launch parameters                               | Authorize extras                                                                             | Token context                                                  |
| --------------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| **EHR launch**        | User is already in a chart or portal; EHR opens the registered launch URL | `iss` = FHIR base URL; `launch` = opaque handle | Scope `launch` plus `launch={token}`; `aud` **equals** that `iss`                            | EHR supplies `patient` / `encounter` from the current session  |
| **Standalone launch** | User opens the app outside the EHR                                        | None                                            | Scope `launch/patient` (and/or `launch/encounter`); `aud` = the FHIR base you intend to call | Authorization server collects context (often a patient picker) |

Discovery is `GET {iss}/.well-known/smart-configuration` (EHR launch) or `GET {fhir-base}/.well-known/smart-configuration` (standalone). You need `authorization_endpoint`, `token_endpoint`, and `code_challenge_methods_supported` including `S256`.

Authorize (minimum):

| Parameter                                  | EHR launch                                              | Standalone                         |
| ------------------------------------------ | ------------------------------------------------------- | ---------------------------------- |
| `response_type`                            | `code`                                                  | `code`                             |
| `client_id`                                | registered                                              | registered                         |
| `redirect_uri`                             | exact registered URI                                    | exact registered URI               |
| `scope`                                    | `launch` + clinical scopes + optional `openid fhirUser` | `launch/patient` + clinical scopes |
| `state`                                    | unpredictable, bound to the session                     | same                               |
| `aud`                                      | FHIR resource base (**same as `iss`**)                  | FHIR resource base you will call   |
| `launch`                                   | echo the opaque token                                   | omit                               |
| `code_challenge` / `code_challenge_method` | S256 PKCE                                               | S256 PKCE                          |

[All SMART apps SHALL support PKCE](https://hl7.org/fhir/smart-app-launch/app-launch.html#considerations-for-pkce-support). Servers SHALL support `S256` and SHALL NOT support `plain`. Token exchange POSTs `grant_type=authorization_code`, the `code`, the same `redirect_uri`, and `code_verifier`. Public apps cannot protect a client secret; confidential apps should prefer asymmetric client authentication when the EHR offers it.

`aud` exists so a genuine bearer token is not sent to a counterfeit resource server. The resource server must check that the token was issued for *its* FHIR base. If `aud` ≠ the server you call, expect a rejected token even when the code exchange succeeded.

## Identifier traps

| Trap                                                              | What breaks                                    | Fix                                                                                            |
| ----------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `iss` host ≠ `aud` host                                           | Token works in discovery, 401 on Patient read  | EHR launch: `aud` is the launch `iss`. Do not rewrite the hostname to a “pretty” proxy         |
| Launch token not echoed                                           | EHR cannot bind the OAuth request to the chart | Scope `launch` **and** query `launch=` with the same opaque value                              |
| Sandbox client ID in production                                   | Epic (and others) issue **two** client IDs     | Non-production ID against the sandbox; production ID only after “ready for production”         |
| PKCE verifier lost across the redirect                            | `invalid_grant`                                | Store `code_verifier` in app-specific storage keyed by `state`, not in a world-readable cookie |
| `redirect_uri` trailing-slash drift                               | Authorization denied                           | Byte-for-byte match with registration                                                          |
| Following a `reference` to another FHIR host with the same bearer | Token theft / wrong audience                   | New authorization for that `aud`; do not forward the token                                     |

Epic-specific testing topic, not a delivery promise: [Epic on FHIR](https://fhir.epic.com/Documentation) documents a Current Sandbox at `https://fhir.epic.com/interconnect-fhir-oauth/` and separate production community-member bases. Backend OAuth is a JWT to the token endpoint (SMART Backend Services, with Epic differences). The sandbox auto-maps your client to a user; a live Epic community member requires an ECSA to map the client ID to an audit user **before** a backend token is issued. Capability, scopes, and patient selection **differ** between sandbox and that member. Put those deltas on the test matrix. Do not invent an “8-week App Orchard” SLA — App Orchard is retired, and no duration here is a quote.

## Test fixtures

De-identified. These URLs and tokens are shapes, not credentials.

EHR launch URL the EHR opens:

```
https://app.example.org/launch?iss=https%3A%2F%2Fehr.example.org%2Ffhir&launch=xyz123
```

Authorize (line breaks for reading):

```
https://ehr.example.org/authorize?
  response_type=code&
  client_id=growth-chart-example&
  redirect_uri=https%3A%2F%2Fapp.example.org%2Fafter-auth&
  launch=xyz123&
  scope=launch%20patient%2FPatient.rs%20patient%2FObservation.rs%20openid%20fhirUser&
  state=98wrghuwuogerg97&
  aud=https%3A%2F%2Fehr.example.org%2Ffhir&
  code_challenge=YPXe7B8ghKrj8PsT4L6ltupgI12NQJ5vblB07F4rGaw&
  code_challenge_method=S256
```

Standalone omits `launch` and uses `scope=launch/patient patient/Patient.rs ...` with the same `aud` discipline.

Minimum tests:

1. EHR launch: `iss` captured, `launch` echoed, `aud` == `iss`, PKCE round-trip, `patient` on the token response.
2. Standalone: patient picker, `launch/patient` granted, no leftover `launch` parameter.
3. Wrong `aud` (token requested for server A, call server B) — must fail.
4. Replay of an authorization code — must fail.
5. Epic (or analogue): sandbox client ID against sandbox; production client ID against a member FHIR base; document the first scope that exists in one and not the other.

Never commit real client secrets, JWKs, or access tokens. Rotate anything that has been in a screenshot.

## Failure modes and rollback

| Failure                                 | Detection                                                                  | Rollback                                                                                                           |
| --------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| User stuck on authorize                 | Missing `state` round-trip or blocked third-party cookies in an EHR iframe | Break out of the iframe if the EHR requires it; never drop `state`                                                 |
| Token for the wrong patient             | `patient` on the token ≠ chart context                                     | Trust token context, not a Patient id the app cached from the last launch                                          |
| Refresh after logout                    | `online_access` vs `offline_access` confusion                              | EHR launch usually wants online access; do not request offline unless the product actually needs unattended access |
| Sandbox-only scopes in a customer build | First production 403                                                       | Scope matrix: sandbox × one non-prod member × prod; ship the intersection                                          |
| Confidential secret in a public SPA     | Extractable client secret                                                  | Public + PKCE, or a BFF that holds the secret                                                                      |

Disable a misbehaving app by revoking the client at the EHR and rotating keys. There is no “undo launch” other than rejecting the token and sending the user back to the chart.

## Related service

If you need an Epic-facing SMART launch (provider embedded, patient standalone, or the test matrix against sandbox vs a member), that is [Epic integration](https://alabenaicha.me/services/epic-integration). For a scoped launch spike, use [contact with project intent](https://alabenaicha.me/contact?intent=project).

This page is not an Epic Showroom listing, not Vendor Services membership, not a security assessment, and not a timeline. Backend-services JWT vs patient vs provider SMART is the [Epic FHIR for HealthTech startups](https://alabenaicha.me/insights/epic-fhir-for-healthtech-startups) decision page.
