Skip to content
Skip to content

Anatomy of a successful SMART on FHIR launch ​

When a SMART on FHIR launch fails, the error rarely says which of the seven steps broke. The specification tells you what should happen; the vendor's documentation tells you what their server expects; neither shows you a working launch end to end, so there is nothing to compare your own traffic against.

This is that comparison. Every request and response below was captured against Epic's public developer sandbox on 2 November 2026 and redacted by hand — secrets and one-time values replaced by a description of their shape and length, structure and lengths left intact. Nothing here is reconstructed or idealized.

1. Scope and audience ​

Audience: a developer whose SMART on FHIR launch against Epic is failing, who has read the spec, and who cannot tell from the error which of the seven steps actually broke.

What this document is: an annotated transcript of a launch that works, so you can diff your own traffic against it. Comparing against a known-good launch is the technique the community recommends, and until now there has been no public capture to compare against.

What this document is not: a tutorial on writing a SMART app, a substitute for the specification, or an Epic-specific integration guide.

The premise: when a SMART launch fails, the information needed to diagnose it is usually already on the wire — in a parameter, a decoded token payload, or the difference between what was requested and what was granted — and the error message almost never points at it. That is why this document is a transcript rather than a troubleshooting flowchart, and why it marks the two cases where the premise does not hold: §3.3, where the failure is a sync you cannot see, and §3.10, where there is no traffic to read at all.

If you have a specific error in hand, skip to the symptom table at the head of §3 and start there. §4 is the full procedure for diffing your own traffic against this one.

Terms, expanded once. FHIR (Fast Healthcare Interoperability Resources) is a REST API over clinical records — Patient, Encounter, Observation and the rest are its resources. SMART App Launch is an OAuth 2.0 profile for obtaining a token to call that API, plus a way for an EHR (electronic health record system) to hand a running app the context of which patient and encounter the clinician is looking at. If you know OAuth 2.0, almost everything below will be familiar; the launch context in §2.2 and the scope families in §2.5 are the parts that are not.

Versions this describes: SMART App Launch v2.2.0 (STU 2.2), based on FHIR R4 (4.0.1), published 2024-04-30 (spec).


2. The seven steps ​

The specification names seven top-level steps (spec):

  1. Register app with the EHR (one-time, may be out of band)
  2. Launch — standalone or EHR launch
  3. Retrieve .well-known/smart-configuration
  4. Obtain authorization code
  5. Obtain access token
  6. Access the FHIR API
  7. Refresh the access token

Step 1 produces no HTTP traffic at all — registration happens on a web form, out of band, possibly months earlier. Step 2 produces exactly one request, and it is inbound: the EHR navigates the browser to your launch URL, carrying iss and launch. §2.2 shows that request as captured.

The distinction matters more than it sounds. An app's outbound capture is empty until step 3, so a developer watching only outbound traffic sees nothing and may conclude the launch never happened. It did; it arrived. But a launch that genuinely never reaches your app produces nothing anywhere — and that case is not diagnosable from traffic at all, because the cause is upstream configuration. Distinguishing "nothing outbound yet" from "nothing arrived" is the first fork in the road, and the inbound request is what tells them apart.

2.1 Step 1 — Registration (out of band, no traffic) ​

At registration, every SMART app registers one or more fully-specified redirect_uri values, and may register one or more fully-specified launch URLs. The EHR returns a client_id. Confidential clients additionally establish either a JSON Web Key Set / JWKS URL (asymmetric authentication) or a client secret (symmetric).

Launch URLs and redirect_uri values are not enforced alike, and the asymmetry is load-bearing. Epic states that launch URLs are not registered with or checked against your app record — the customer configures where to launch you from, on their side. redirect_uri values, by contrast, are registered and matched exactly (§3.5).

Two consequences follow, and both explain failures that otherwise look inexplicable. A launch URL can be changed without touching the app record, so "I didn't change anything" and "the launch broke" are compatible. And because nothing validates the launch URL, a launch can be configured to point anywhere — which is why §2.2 insists on allowlisting iss rather than trusting that only legitimate systems will ever call you.

Do not register your launch URL as a redirect_uri unless the launch URL and the redirect URI really are the same endpoint. Registering it twice buys nothing and widens the set of URLs an authorization code can be sent to.

Epic-specific, from Epic's App Creation & Request Process Overview (Epic). Registration is self-serve and immediate: on save, the website creates an app record and assigns both a production and a non-production client ID. The Create an App form asks for:

  • app name
  • app consumer type
  • applicable APIs
  • default FHIR version
  • applicable redirect URIs
  • whether the confidential client profile will be used — Epic requires it for persistent access, i.e. refresh tokens. This is Epic policy, not a protocol rule, and it only bites if you need more than about an hour of API access — see §2.7.

Once you save the app and mark it Ready for Sandbox, the record stays in Draft status — Draft is the normal state for a sandbox-only app — and changes may take up to 1 hour to sync with the sandbox. That one-hour window is the officially documented version of what the community threads describe as mysterious propagation delay — see §3.3.

Epic also publishes scoping guidance worth taking seriously, because over-scoping is a non-obvious cause of later friction: request only the APIs the app actually needs, because the client ID's API list is what customers' security and privacy reviewers assess, and over-scoping can delay or block adoption and inflate the apparent licensing cost to the customer.

Epic-specific, from the OAuth 2.0 Tutorial, checked 2026-11-02. This applies to backend OAuth 2.0 clients (client_credentials), not to the user-facing EHR launch this document otherwise describes, and the timeline has more steps than a one-line summary suggests:

A citation trap, if you check any of this yourself.fhir.epic.com/Documentation is a single-page application: a request for any one docId returns a bundle containing every page's text, well over a megabyte. So "I fetched ?docId=oauth2 and the text was there" does not establish that the text is on that page — only that it is somewhere in Epic's documentation. Anyone scripting against these URLs will hit this.

The table's rows are also Epic release versions, not calendar dates. Treat "Feb 2026" as a version label; when a given customer reaches it depends on their upgrade schedule.

Epic's own sources are not fully consistent on these dates. A developer management notice gives 23 February 2026 as the point at which Backend Systems apps require a JKU — the jku JWT header claim, a URL pointing at the JWKS introduced above — for a customer environment, while the tutorial below puts the customer cutoff at May 2026 and February at the sandbox. The two may describe different things. Confirm against your own Epic contact before planning a migration around either date.

Epic versionSandboxCustomer environments
Feb 2025 and priorOptionalOptional; each customer could enforce, off by default
Aug 2025No new static key uploads for sandbox use—
Feb 2026Static keys no longer supported for backend OAuth; no static key uploads for new apps or new customer download requestsNo static key uploads for new apps or new customer download requests; rotation still allowed for existing live customers
May 2026—Static keys no longer supported; customers must manually configure the JKU you specify — Epic calls this the local JKU
Aug 2026—Customers may configure local JWKs from a .pem you supply, instead of a JKU

Epic recommends supporting a JKU rather than static keys, for key rotation. Note the consequence of the .pem route: if your keys change you must communicate them to each customer individually.

2.2 Step 2 — EHR launch (the EHR calls you) ​

The EHR opens a browser instance or iframe pointing at the app's registered launch URL, with two required parameters:

ParameterRequiredMeaning
issrequiredThe EHR's FHIR base URL. The app uses this to discover everything else.
launchrequiredOpaque handle for this specific launch and its EHR context. Must be echoed back at authorization time.

Specification example:

GET https://app/launch?iss=https%3A%2F%2Fehr%2Ffhir&launch=xyz123

Captured against Epic's R4 sandbox, 2026-11-02 (line breaks added, values URL-encoded as sent):

GET /launch
      ?iss=https%3A%2F%2Ffhir.epic.com%2Finterconnect-fhir-oauth%2Fapi%2FFHIR%2FR4
      &launch=<launch token — 1,017-character JWT, redacted>
Host: localhost:3000

Decoded, iss is:

https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4

That iss is the value every later step derives from, and aud in step 4 must equal it. A mismatch between the two is silent and high-frequency.

Allowlist iss before you fetch anything from it. iss arrives in a query string, which means it arrives from whoever triggered the launch. An app that discovers endpoints from an arbitrary iss and then posts its authorization code and PKCE verifier to that document's token_endpoint has handed an attacker the code: trigger a launch at the app's URL with an iss you control, and the app delivers the credential to you. This is the OAuth mix-up attack (RFC 9700 §4.4), and SMART states the rule in its own terms — an app SHALL NOT forward values passed back to its redirect URL to any other arbitrary or user-provided URL.

Keep a list of the iss values belonging to organizations you actually integrate with, and reject anything else before the discovery request, not after. Epic publishes its community members' FHIR endpoints, so the list is obtainable rather than guesswork.

The aud = iss rule above makes this worse, not better, which is why the two are stated together: an app that faithfully copies a hostile iss into aud has done exactly what the specification asks and still ended up pointing at the attacker. Conformance is not the control here; the allowlist is.

The launch parameter is nominally opaque, and in Epic's case it is also a readable JWT. Treat it as opaque in code — the specification says to, and its contents are not contractual. But when a launch fails, decoding it costs nothing and answers the question immediately. This one carried:

json
{
  "aud": "urn:oid:1.2.840.114350.1.13.0.1.7.3.688884.100",
  "client_id": "<client id — 36-character UUID, redacted>",
  "epic.tokentype": "launch",
  "iat": 1793626670,
  "exp": 1793626970,
  "jti": "<uuid>",
  "sub": "<opaque user id>"
}

The aud here is the launch token's own audience — an OID identifying the Epic instance that issued it. It is not the aud parameter you send at step 4, which is the FHIR base URL from iss. Two different claims, same name, 30 lines apart; §2.4 covers the one you have to get right.

Two more things fall out of that payload. The token names the client it was issued for, so a launch that fails with invalid_grant is usually one line of base64 away from being explained — see §3.9. And exp minus iat is 300 seconds, so a launch token pasted into a terminal and used five minutes later is already dead.

2.3 Step 3 — Discovery ​

The app appends .well-known/smart-configuration to the FHIR base URL from iss and issues a GET with an Accept header supporting application/json. The EHR returns a SMART configuration JSON document containing, among other things, the authorization_endpoint and token_endpoint.

Captured 2026-11-02:

GET /interconnect-fhir-oauth/api/FHIR/R4/.well-known/smart-configuration
Host: fhir.epic.com
Accept: application/json

200 OK
Content-Type: application/json
json
{
  "authorization_endpoint": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/authorize",
  "token_endpoint": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_post", "client_secret_basic", "private_key_jwt"
  ],
  "scopes_supported": [
    "epic.scanning.dmsusername", "fhirUser", "launch", "openid", "profile"
  ],
  "response_types_supported": ["code"],
  "capabilities": [
    "launch-ehr", "launch-standalone",
    "client-public", "client-confidential-symmetric",
    "client-confidential-asymmetric",
    "context-banner", "context-style",
    "context-ehr-patient", "context-ehr-encounter",
    "context-standalone-patient",
    "permission-offline", "permission-patient", "permission-user",
    "permission-v1", "permission-v2",
    "sso-openid-connect", "authorize-post"
  ],
  "grant_types_supported": [
    "authorization_code", "refresh_token", "client_credentials",
    "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "urn:ietf:params:oauth:grant-type:token-exchange"
  ],
  "code_challenge_methods_supported": ["S256"],
  "issuer": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2",
  "jwks_uri": "https://fhir.epic.com/interconnect-fhir-oauth/api/epic/2019/Security/Open/PublicKeys/530027/OIDC"
}

Nothing is redacted here: the document is public and identical for every caller.

Before diffing this against your own server's document, know what is actually required. Ten fields appear above; SMART marks only four REQUIRED:

StatusFields
Requiredtoken_endpoint, grant_types_supported, capabilities, code_challenge_methods_supported
Conditionalauthorization_endpoint (required when the server supports launch-ehr or launch-standalone); issuer and jwks_uri (required when capabilities includes sso-openid-connect)
Recommendedscopes_supported, response_types_supported and others

Note what that implies: authorization_endpoint — which every flow in this document depends on — is conditional, not required, and scopes_supported is only recommended. A server omitting fields is not necessarily broken, and treating every difference as a defect will have you chasing a lot of nothing. Diff for the fields your flow depends on.

This response is the single most useful artifact in the whole capture, because it is the EHR telling you where everything is. A reader guessing at endpoint URLs has skipped this step. Three things in it are worth reading rather than skimming:

  • code_challenge_methods_supported is ["S256"] only — and this is one field worth more than a presence check, because SMART says it SHALL include S256 and SHALL NOT include plain. A server advertising plain is non-conforming, which is a stronger statement than "Epic happens to refuse it." Whatever your library defaults to, plain is not available here.
  • capabilities is the honest answer to "does this server support what I am about to do." launch-ehr and client-public are what make the flow in this document possible at all.
  • scopes_supported lists five scopes — and the token response in §2.5 grants eight. That is not a contradiction, and §3.7 explains why, but it is the first place the two can be seen disagreeing.

What it does not declare matters: whether the token endpoint permits cross-origin requests. Nothing in the SMART configuration document answers that, and it decides whether a browser-only public client is viable at all. Tested directly: a page-JavaScript client on http://localhost:3000 redeemed its code successfully, and the token endpoint's response carries access-control-allow-origin: *. The token endpoint is the one that matters — a cross-origin POST has to be permitted for the code to be redeemed in page JavaScript at all. So it works on Epic's sandbox — an observation about that sandbox, not a guarantee about customer instances; §3.11 covers what happens when it is not true somewhere else.

Related, from the community: production auth endpoints are reportedly not listed the way sandbox ones are; one developer found them by requesting the FHIR /metadata conformance statement from each production endpoint (Google Groups) — this thread predates 2024, so confirm against current endpoints before relying on it.

2.4 Step 4 — Authorization code request ​

This is where launches most often fail. The app navigates the browser to the EHR's authorize endpoint with:

ParameterRequiredNotes
response_typerequiredFixed value code.
client_idrequiredThe client identifier from registration.
redirect_urirequiredMust match a pre-registered value.
launchconditionalEHR launch only; must match the launch received in step 2. Omitted for standalone.
scoperequiredMust include launch (EHR launch) or launch/patient-style context requests (standalone), plus resource scopes, plus openid fhirUser if user identity is needed.
staterequiredOpaque, unpredictable, at least 122 bits of entropy. Must be validated on return.
audrequired*The FHIR resource server URL. In an EHR launch this equals iss.
code_challengerequiredPKCE. BASE64URL(SHA256(verifier)) — 43 characters.
code_challenge_methodrequiredPKCE; servers must support S256 and must not support plain.

* Every row above is the specification's, including aud, which it lists as required and of which it says "servers SHALL support the aud parameter". Epic's own wording is in two places weaker than that: it calls aud "required for most workflows", and the specification's instruction for an EHR launch — "the app adds a launch scope and a launch={launch id} URL parameter" — is one Epic enforces as a hard requirement (see §3.4). Where this document says "Epic-specific", it means a rule with no counterpart in the specification at all; a vendor restating a specification rule more or less firmly is not that.

All SMART apps must support PKCE; the EHR must verify the code_verifier when the code is exchanged.

Specification example:

Location: https://ehr/authorize?
            response_type=code&
            client_id=app-client-id&
            redirect_uri=https%3A%2F%2Fapp%2Fafter-auth&
            launch=xyz123&
            scope=launch+patient%2FObservation.rs+patient%2FPatient.rs+openid+fhirUser&
            state=98wrghuwuogerg97&
            aud=https://ehr/fhir

Captured 2026-11-02. Every parameter decoded:

GET /interconnect-fhir-oauth/oauth2/authorize
Host: fhir.epic.com

  response_type         code
  client_id             <36-character UUID, redacted>
  redirect_uri          http://localhost:3000/callback
  scope                 launch openid fhirUser
  state                 <36-character UUID, redacted>
  aud                   https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4
  code_challenge        <43-character base64url, redacted>
  code_challenge_method S256
  launch                <1,017-character JWT, redacted>

302 Found

redirect_uri is http://localhost:3000/callback here because this is a development harness. RFC 9700 §2.1 is explicit: authorization servers "MUST NOT allow redirection URIs that use the http scheme except for native clients that use loopback interface redirection", per RFC 8252 §7.3. A web app's redirect URI must therefore be https. Note what the exception is and is not: it covers native clients on a loopback address, permanently, and it is not a general license to use http while developing. The same value appears at step 5 and in §2.8 for the same reason.

Five things to check against your own request, in the order they go wrong:

  • aud should be the iss value from step 2, copied rather than reconstructed. Epic calls aud "required for most workflows". The specification does not say whether a server must normalize aud before comparing it, in either direction — so a trailing slash, a case difference or a rebuilt URL may or may not match, and which it is depends on the server. We could not establish it either way, which is itself the reason to copy the string you received: the question then never arises.
  • scope contains launch. Without it the EHR has no reason to apply the launch context and may re-prompt for login; see §3.4.
  • code_challenge_method is S256, matching what discovery advertised. plain is refused — and note that per RFC 7636 §4.3 the parameter defaults to plain when omitted, so a library that does not set it explicitly fails here rather than falling back to something weaker.
  • code_challenge is 43 characters. That is not a coincidence and it is the cheapest thing to check when PKCE fails. The verifier is 43–128 characters from the unreserved set, and the challenge is BASE64URL(SHA256(ASCII(verifier))) — a SHA-256 digest is 32 bytes, which is exactly 43 base64url characters unpadded. A challenge that is 44 characters has padding that should have been stripped; one that is 64 is hex rather than base64url; one that equals the verifier means the library silently used plain. Each of those is a one-glance diagnosis from the captured value.
  • launch is echoed back unmodified from step 2.

Note what is absent: no client secret, anywhere. This is a public client — it has no secret to send, because a secret shipped inside a browser or a desktop binary is not a secret.

PKCE does not replace the secret, and it is worth being precise about this because the shorthand is everywhere. PKCE binds the authorization code to the entity that began the flow, so an intercepted code is useless without the verifier. It does not authenticate the client — the authorization server still cannot tell who it is talking to. That is what a secret or a signed assertion does, and a public client simply forgoes it.

Nor is PKCE a public-client measure. SMART requires it of everyone: "All SMART apps SHALL support PKCE", confidential apps included. A confidential client sends both.

On success, the EHR redirects to the app's redirect_uri with code and state. Authorization codes are short-lived — the spec says "usually expiring within around one minute."

302 Found
Location: http://localhost:3000/callback
            ?code=<authorization code — 1,017-character JWT, redacted>
            &state=<echoed verbatim from the request above>

Verify state yourself before touching code. The redirect is the one point an attacker can reach; an unmatched state means discard the response, not debug it.

Comparing the value is only half of it. SMART requires state to be "securely tied to the user's current session" — typically by storing it server-side against the session, or in a cookie bound to it. An app that merely checks the returned state against one it generated, without binding it to this user's session, still accepts a redirect replayed from another session. That is login CSRF, and it is the attack state exists to stop; the entropy requirement alone does not stop it.

Note the tension with §3.8, where Epic's own troubleshooting advice for a 404 or 500 at the authorize endpoint is to shorten the state parameter. The floor is not negotiable: 122 bits is about 21 base64url characters, and the 36-character UUID captured above is already close to it. If state is long enough for Epic's advice to apply, the length is coming from application data stored inside it — which SMART advises against anyway. Move that data server-side or use Epic's POST-based authorization; do not shorten below the entropy floor.

Epic's authorization code is also a JWT, and decoding it shows "epic.tokentype": "code" where the launch token in §2.2 showed "launch" — a quick way to confirm which artifact you are actually holding when a flow has gone sideways.

This step is structurally opaque. The authorization decision happens server-side, inside the EHR, after the app has done everything it can observe. When it fails, the app's own logs contain nothing useful — which is precisely why comparing the outbound request against a known-good one is the community's recommended technique.

2.5 Step 5 — Token exchange ​

HTTP POST to the token endpoint, application/x-www-form-urlencoded:

ParameterRequiredNotes
grant_typerequiredFixed value authorization_code.
coderequiredFrom step 4.
redirect_urirequiredThe same one used in step 4.
code_verifierrequiredPKCE; verified against code_challenge.
client_idconditionalRequired for public apps; omitted for confidential apps.

The response is JSON containing access_token, token_type (fixed value Bearer), scope (the granted scope, which may differ from what was requested), expires_in (which the specification marks recommended here and required in a refresh response), optionally id_token, refresh_token, and — if launched in a patient context — context parameters such as "patient": "123". The response must carry Cache-Control: no-store and Pragma: no-cache.

Captured 2026-11-02:

POST /interconnect-fhir-oauth/oauth2/token
Host: fhir.epic.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<redacted>
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
&client_id=<redacted>
&code_verifier=<43-character base64url, redacted>

200 OK
Content-Type: application/json
json
{
  "access_token": "<JWT, 1,052 characters, redacted>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "user/Encounter.read user/Patient.read user/Practitioner.read fhirUser launch launch/encounter launch/patient openid",
  "state": "<echoed, redacted>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "id_token": "<JWT, 829 characters, redacted>",
  "patient": "eOPSKZbz6YIgoilQprzPy0Q3",
  "encounter": "e.vrhozW4kARVUOjy8NmSqA3",
  "loginDepartment": "e4W4rmGe9QzuGm2Dy4NBqVc0KDe6yGld6HW95UuN-Qd03",
  "need_patient_banner": "false",
  "smart_style_url": "https://fhir.epic.com/interconnect-fhir-oauth/api/epic/2016/EDI/HTTP/style/…",
  "__epic.dstu2.patient": "T1wI5bk8n1YVgvWk9D05BmRV0Pi3ECImNSK8DKyKltsMB",
  "unconfirmed_status": ""
}

The id_token payload, decoded (signature omitted):

json
{
  "iss": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2",
  "aud": "<the client_id, redacted>",
  "sub": "evNp-KhYwOOqAZn1pZ2enuA3",
  "fhirUser": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/Practitioner/evNp-KhYwOOqAZn1pZ2enuA3",
  "iat": 1793626818,
  "exp": 1793627118
}

The patient and encounter ids above are synthetic sandbox data and are not redacted — see §2.6 for why. We did not verify whether they are identical for every developer, and the sandbox is refreshed weekly, so treat the ids as stable-in-practice rather than guaranteed and re-verify if you are diffing against them.

The scope in the response is the granted scope, and here it is not the requested one. The request in §2.4 asked for:

launch openid fhirUser

and got back eight scopes — those three, plus launch/patient and launch/encounter implied by the launch context, plus three user/*.read scopes that appear only because the corresponding Incoming APIs are selected on the app's registration record. Nothing in the request named them.

So the granted set is not a subset of the requested set. It is a different set, computed from registration state the request cannot see, and the token response is the first and only place the two are visible side by side. A launch that "succeeds" and then 403s on every FHIR call is almost always this — see §3.7 for the 403's exact signature.

One thing to notice before you diff these against the specification's examples: the syntax differs. Epic granted user/Patient.read. SMART v2 scope syntax is user/Patient.rs, where r is read and s is search, and every example in the v2 specification uses it. The v1 form above is not an error. SMART keeps v1 scopes supported and defines the mapping itself: servers SHOULD process v1 scopes, with .read understood as .rs, .write as .cud, and .* as .cruds. So user/Patient.read and user/Patient.rs express the same grant — the suffix letters are cruds (create, read, update, delete, search) rather than words. But if you are diffing a real v1-style grant against a specification written in v2 style sees a mismatch on every line and concludes something is wrong.

The practical rule: check what your server advertises before deciding your scopes are malformed. SMART defines a permission-v1 capability for servers that process v1 scopes. It does not define a permission-v2 capability — Epic's document above advertises one anyway, which is a vendor extension rather than a specification feature, and worth knowing before you go looking for it in the spec.

The fhirUser claim is the other thing worth extracting here: it resolves the authenticated user to a real FHIR resource, in this case a Practitioner. On a patient-facing launch it would be a Patient or RelatedPerson. It is only populated if the matching read API is registered — Practitioner.Read here — which is the same registration dependency in a second place.

Decoding an id_token is not validating it, and this document shows you the decoding. Everything above is displayed by base64-decoding the middle segment of a JWT, which performs no checks whatsoever. A JWT is signed precisely so that its contents cannot be trusted unsigned, and an attacker-supplied token decodes just as cleanly as a real one.

Before any claim in it is used — fhirUser very much included — SMART requires the token to be validated per OpenID Connect: fetch the issuer's configuration, follow its jwks_uri, verify the signature, and check iss, aud, exp and nonce where applicable. The jwks_uri is in the discovery document printed in §2.3, two sections above; this document shows you both halves and it is worth saying plainly that the second half is not optional.

Use a library. Hand-rolled JWT validation is a well-populated category of security advisory, and alg: none and algorithm-confusion attacks exist because people wrote their own.

Nothing in a capture can tell you whether an app validated the token, which is precisely why it needs saying here rather than being left to the traffic to reveal.

2.6 Step 6 — FHIR API call ​

GET https://ehr/fhir/Patient/123
Authorization: Bearer {{access_token}}

Captured 2026-11-02, using the patient id the token response handed back — not a hardcoded one:

GET /interconnect-fhir-oauth/api/FHIR/R4/Patient/eOPSKZbz6YIgoilQprzPy0Q3
Host: fhir.epic.com
Authorization: Bearer <access token, redacted>
Accept: application/fhir+json

200 OK
Content-Type: application/fhir+json; charset=utf-8
Cache-Control: no-cache,no-store
json
{
  "resourceType": "Patient",
  "id": "eOPSKZbz6YIgoilQprzPy0Q3",
  "active": true,
  "name": [{ "use": "official", "text": "Anna Cadence",
             "family": "Cadence", "given": ["Anna"] }],
  "gender": "female",
  "birthDate": "1983-09-09",
  "identifier": [
    { "type": { "text": "EPIC"     }, "value": "E2651" },
    { "type": { "text": "EXTERNAL" }, "value": "Z4529" },
    { "type": { "text": "FHIR"     }, "value": "T1wI5bk8n1YVgvWk9D05BmRV0Pi3ECImNSK8DKyKltsMB" }
  ]
}

Abridged — the real response is about 8 KB and includes US Core race and ethnicity extensions, addresses, telecom, contacts and roughly ten identifier systems.

Nothing here is redacted, and the reason needs stating carefully. Anna Cadence is a record in Epic's public developer sandbox, offered by Epic's own test launcher as a selectable patient. Epic's terms for that sandbox are explicit that it is to be populated only with sample or synthetic data and that developers agree not to put individually identifiable information into it. So the record is synthetic.

Two observations that only exist because this step was actually performed:

  • The patient id from the token response is an opaque FHIR id, not an MRN. The MRN-like values (Z4529, E2651) appear inside the resource as identifiers. Searching by MRN is a different and more failure-prone path — see §3.8's NO-PATIENT-FOUND.
  • The response carries access-control-allow-origin: *. So does the token endpoint's response, which is the one that decides whether a browser-only client is viable — a cross-origin POST has to be permitted for the code to be redeemed in page JavaScript at all. (CORS does not apply to the authorize request at all: that is a top-level navigation, not a scripted fetch. The token endpoint's headers are the evidence that matters, and they were in the capture all along.) That is an observation about Epic's sandbox, not a promise about a customer instance.

This is also the step where an under-registered API produces the bare 403 of §3.7 instead of the body above — the request is identical, and only the registration differs.

2.7 Step 7 — Refresh ​

Requested via online_access (EHR launch) or offline_access (EHR or standalone launch) — the specification's own wording. POST with grant_type=refresh_token. Any scope sent must be a strict subset of what was originally granted. Note that expires_in is recommended in the initial token response but required in a refresh response.

Not captured. The app used for every capture in this document is registered without persistent access, so Epic issued no refresh_token and there is nothing real to show. What follows is specification summary only, not a captured exchange.

Two facts from the captures do bear on it. Discovery advertises refresh_token in grant_types_supported and permission-offline in capabilities, so the server supports it; and the token response in §2.5 contains no refresh_token, which is the registration declining rather than the server. Epic's guidance is that persistent access is uncommon, needed only for more than an hour of API access, and that it requires a confidential client — which is why the app used here, a public client without it, was issued none.

Capturing a real refresh would mean a differently registered app, and probably a differently shaped document. It is a gap in this reference, stated rather than papered over.

2.8 The standalone launch, beside the EHR launch ​

Captured 2026-11-02 against the same sandbox, the same app and the same client id, so every difference below is the launch type and nothing else.

In a standalone launch, step 2 is the launch — it simply generates no HTTP request, because nobody navigates the browser to the app. The app starts the flow itself: straight to /authorize with no launch parameter, and the EHR answers with its own login page — in the sandbox, Epic's Hyperspace login, using Epic's published test user FHIRTWO. Step 2 still happens — the app performs the launch itself, rather than receiving one — so the difference is no inbound request, not no step.

GET /interconnect-fhir-oauth/oauth2/authorize
  response_type         code
  client_id             <redacted>
  redirect_uri          http://localhost:3000/callback
  scope                 openid fhirUser
  state                 <redacted>
  aud                   https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4
  code_challenge        <redacted>
  code_challenge_method S256
                        ← no `launch` parameter. That is the whole difference.

The token response, complete:

json
{
  "access_token": "<JWT, 1,052 characters, redacted>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "user/Encounter.read user/Patient.read user/Practitioner.read fhirUser openid",
  "state": "<echoed, redacted>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "id_token": "<JWT, 829 characters, redacted>"
}

Put beside §2.5, the difference is stark:

EHR launchStandalone
launch parameter sentyesno
User authenticatesalready, in the EHRat the EHR's login page
Granted launch scopeyesno
Granted launch/patient, launch/encounteryesno
user/*.read scopesyesyes — identical
patient in token responseyesabsent
encounteryesabsent
loginDepartment, need_patient_banner, smart_style_urlyesabsent
__epic.dstu2.patientyesabsent
id_token with fhirUseryesyes

Three things worth drawing out, because they are easy to get wrong in the abstract and obvious once the two responses sit side by side:

  • The user/*.read scopes are identical across both. They come from the app's registration record, which did not change. This is the cleanest possible demonstration of §3.7: the launch type determines the launch* scopes, registration determines the user/* ones, and the request determines neither.
  • Everything patient-shaped is simply gone. A standalone app that expects patient in the token response will read undefined and fail somewhere later, with nothing in the OAuth exchange marked as an error. If you need patient context in a standalone launch you must request launch/patient explicitly, which triggers Epic's patient-selection step.
  • fhirUser is present in both, and in both cases resolved to a Practitioner — a different one, because a different user authenticated. It is the one reliable "who am I acting as" answer across both launch types.

The practical reading: the two flows share step 3 onward almost exactly. Reaching for a standalone launch to "simplify" debugging is reasonable, and Epic's own testing guide suggests it — but understand that it removes the launch context you may be trying to debug.


3. Failure modes, with sources ​

Every entry below is from a public thread or Epic's own documentation, not reconstruction.

Start here if you have an error in hand. The subsections are named for causes, because that is what they explain; this table is keyed on what you actually have in front of you.

What you seeGo to
Bare "OAuth2 Error" at /authorize, no detail§3.1, then §3.9 and §3.2
"Something went wrong trying to authorize the client"§3.8.1
"An error has occurred. The request is invalid." after login§3.1, then §3.9
invalid_grant, "Failed to redeem a launch token"§3.9
invalid_grant at the token endpoint, other wording§3.5 (same redirect_uri at both steps), then §2.4 — the code may be spent or expired, or the PKCE verifier may not match
invalid_launch_token§3.8.2, then §3.2
400 invalid_client, invalid_request or unauthorized_client§3.8
403 on every FHIR call, often with an empty body§3.7
403 with "the authenticated user is not authorized"§3.7 to confirm it is the user branch, not the scope branch — this document has no remedy for it
401 with NO-PATIENT-FOUND§3.8.8 — Epic files this under 401, not 403
404 or 500 at /authorize§3.8.3; §2.4 has the long form on state
"This content cannot be displayed in a frame."§3.8.7 — check your own X-Frame-Options too
A CORS error in the browser console§3.11
An EHR launch that re-prompts for login§3.4
redirect_uri rejected§3.5, then §3.8.1 for Epic's actual error string
The redirect never arrives at all§3.8.7, then §3.10
A client library throwing on the discovery document§3.6, then §2.3 — fields it expects may be optional
A launch URL replayed by hand that no longer works§3.9 — launch tokens live 300 seconds and are single-use
It worked last week and nothing changed§3.1, then §3.9, then §2.1 — a launch URL can change without touching the app record
Nothing happens; no request anywhere§3.10

What this catalog does not cover: a launch that completes and then returns the wrong data — an empty Bundle, missing resources, unexpected filtering. That is a FHIR query or Epic configuration problem rather than a launch problem, and nothing in the OAuth exchange will show it to you. If your flow reaches step 6 and returns 200, you are past the scope of this document.

3.1 Bare "OAuth2 Error" at the authorize step ​

A developer building a Python app against Epic's sandbox received a bare "OAuth2 Error" at the authorization step with no detail, which after MyChart login became "An error has occurred. The request is invalid." They had successfully received launch and iss and read the SMART configuration — so the failure was inside the opaque authorize step. Repliers could not diagnose it from the error alone (Google Groups).

Repetition, which is what makes this the common case: in the same thread, Ashok Sharma (March 2024) reported the identical failure with no code or Epic app-setup change — it had worked the week before; sibin c sebastian (May 2024) reported the same; Jose Arzac posted again in August 2024.

The recommended remedy: Sean Nolan suggested testing the client ID against a known-working sample app and comparing the network traffic between the two. Heshan Wanigasooriya separately handed over a working sandbox project so the developer could step through and compare (same thread).

Check, in this order. The error itself carries no information, so work from most to least likely: §3.9 (the launch token was minted for a different client — invisible in every request parameter), §3.2 (production client ID used against the sandbox), §3.8 (Epic's own authorize-step catalog), and only then §3.3 (registration sync).

3.2 Wrong-environment client ID ​

A bare "OAuth2 Error" at /authorize (§3.1) frequently means the server does not recognize the client_id; the fix is to use the non-production client ID against the Epic sandbox. Epic's OAuth 2.0 Tutorial states it directly: "While testing in the Epic on FHIR sandbox, use the non-production Client ID."

Inspection catches this one — it is a diff against a known-good launch. But see §3.9: a client_id the authorize request carries correctly can still be the wrong one for that launch, and no amount of diffing a single capture reveals it.

3.3 Registration not yet propagated ​

Advice in these threads includes simply waiting, because Epic sandbox configuration changes are believed to take a day or two to propagate. One developer resolved an identical OAuth2 error by switching to standalone launch and waiting over 12 hours for the sandbox to recognize the client (Google Groups).

The practical rule: after a registration change, retry on a scale of minutes. If it still fails an hour later, the sync is not your problem — §3.9 is the first thing to rule out, then §3.8. Everything below is the evidence for that rule, and the reason the folklore says otherwise.

Epic documents the interval in three places, and they disagree:

  • Changes to an app on the website may take up to 1 hour to sync with the sandbox (App Creation & Request Process).
  • The Epic Troubleshooting page gives two different figures itself: its "Timing Changes to Your Application" banner says the sandbox "may take up to 30 minutes to sync changes you make to technical settings," while its 403 section says changes to selected APIs "may take up to 1 hour to sync with the Sandbox and 12 hours to sync with an Epic organization's Epic instance" (Epic Troubleshooting).
  • The Developer Testing Guide says there is "a rolling sync between the Build Apps page and the Sandbox" and that you "may need to wait up to 30 minutes for newly added APIs to be recognized in the Sandbox environment," calling this "a common source of 403 errors" (Developer Testing Guide).

The community's 12-hour figure is not wrong — it is right about the wrong environment. Epic's own documentation describes a rolling 12-hour cycle for a customer's Epic instance, and that is where the number comes from. Applied to the sandbox, where almost all early development happens, it is off by an order of magnitude: the sandbox answer is 30 to 60 minutes, not a day or two. A developer told to "wait a day or two" who is an hour past a change is being advised to stop debugging something already fixed.

So the published figures span 30 minutes, 1 hour and 12 hours, and one page carries two of them. The community did not invent this confusion — it inherited it, and nobody had written the figures down side by side.

Measured 2026-11-02: 16 minutes. App 64292 was saved at 07:15 CST and completed a successful EHR launch at 07:31 — shorter than every interval Epic publishes, and roughly two orders of magnitude off the community's "a day or two." One observation is not a service-level guarantee: it establishes that the floor is minutes rather than hours, and nothing more. Nobody should be told their app will be ready in sixteen minutes.

Inspection cannot catch this one. A meaningful share of sync failures are not diagnosable from traffic, because the thing that changed is server-side state with no representation on the wire. What the citations above buy is a bounded wait rather than a shrug — and Epic's own troubleshooting text repeats "wait for the client sync before testing" as a precondition for several unrelated errors, so it is worth excluding early even though you cannot see it.

3.4 Missing launch scope ​

Epic is stronger than "should" here: the launch scope is required for an EHR launch, and has been since Epic's 2018 version. A missing launch scope is the documented cause when an EHR launch keeps re-prompting for login, and a developer hit HTTP 400/302 and fixed it by adding launch to the requested scope (Google Groups). Note that this is the launch scope value, which is a different thing from the launch parameter carrying the launch token; an EHR launch needs both.

3.5 Redirect URI mismatch ​

redirect_uri must match a pre-registered value at step 4, and the same value must be sent again at step 5 — two separate opportunities to get one value wrong.

Matching is exact, not semantic. A trailing slash, http against https, a port that is present in one and absent in the other, a differing case in the path, or an added query parameter all fail. This is deliberate: loose matching on redirect URIs is a well-known open-redirect and token-theft vector, so authorization servers are required to be strict.

The sandbox-specific wrinkle: Epic asks that sandbox redirect URIs carry an epic-sandbox segment where a customer's own identifier would otherwise sit, so a URI that works against the sandbox is usually not the one a customer will register.

3.6 Client library parse failures ​

Some failures are library-level rather than protocol-level: a developer got past a parse failure in the SMART Python client by constructing it with strict=False (client-py #107).

3.7 403 on every FHIR call, often with an empty body — granted scope differs from requested ​

SMART App Launch states that the token response's scope "can be different from the scopes requested by the app" (spec). This produces a successful launch that fails later, which is why it is under-diagnosed.

It has a documented signature. Epic returns 403 with a WWW-Authenticate response header reading:

Bearer error="insufficient_scope", error_description="The access token provided is valid, but is not authorized for this service"

(Epic Troubleshooting). Epic distinguishes two 403 causes: if the problem is the client, there is generally no response body at all, just 403 Forbidden — check the API is attached to your client ID, and wait for the sync. If the problem is the user, Epic returns a message such as "The authenticated user is not authorized to view the requested data."

That distinction is where this section's usefulness ends, in one direction. Everything below solves the client branch — the granted scope is not what you asked for, and the app record is why. If Epic has told you the user is not authorized, your scopes are not the problem: the logged-in test user or clinician lacks the underlying security point in Epic, which is configured in the EHR and is invisible in every capture on this page. Nothing in this document will fix it, and the honest next step is to ask whoever administers that environment.

A bare 403 with an empty body is nearly uninformative on its own, and completely informative once you decode the granted scope from the token response and compare it against the API you called.

Observed against Epic's sandbox on 2026-11-02, and the divergence is wider than "narrower than requested" suggests. The capture requested:

scope=launch openid fhirUser

and Epic granted:

user/Encounter.read user/Patient.read user/Practitioner.read
fhirUser launch launch/encounter launch/patient openid

Three user/*.read scopes the app never asked for, derived from the Incoming APIs selected on the app record, plus launch/encounter and launch/patient that the launch context implied. So the granted set is not a subset of the requested set in either direction — it is a different set, computed from registration state the request cannot see.

That is the fact worth leading with. A developer reading their own authorize request cannot predict what they will be allowed to call; only the token response says. It also reframes §3.3 and this section as one problem rather than two: both are the app record and the request disagreeing, and the token response's scope is the only place that disagreement is visible before a 403 arrives.

"Diff requested against granted" is therefore a concrete, mechanical check rather than an abstraction, and it is the one place where a tool that lines the two sets up beside each other saves you real work. The rest of this flow is perfectly legible in any HTTP client.

Two caveats on reading the capture above as typical. First, this request deliberately asked for no resource scopes at all — launch openid fhirUser and nothing more — because the point was to show what registration adds. A normal request would also carry resource scopes of its own, such as patient/Observation.rs; ask for what you need rather than relying on the app record to supply it, because that reliance is Epic behavior and not portable. Second, to change what registration grants you: on fhir.epic.com, open your app and edit its Incoming APIs list, then wait out the sync in §3.3.

3.8 Epic's own documented error catalog ​

Epic publishes its own error catalog, and for most purposes it is better than the community threads. All from Epic Troubleshooting.

3.8.1 Authorize: "Something went wrong trying to authorize the client" ​

Epic's full message is "Something went wrong trying to authorize the client. Please try logging in again." Epic's checklist:

  • redirect_uri must match a registered URI — case sensitive, exact match; / will not match /index.html even though browsers treat them alike. If URIs were edited, wait for the client sync.
  • client_id must match the app's, and response_type must be code.
  • A backend app seeing this is probably hitting /authorize instead of /token.
  • A proper aud parameter must be present.
  • POST bodies must be application/x-www-form-urlencoded, not JSON, with data in the body rather than the query string — and the reverse for GET.

3.8.2 invalid_launch_token ​

The client ID the launch was issued against must match the client ID in the authorize request, and this occurs when redeeming a launch token at the wrong Epic instance (production vs non-production). This independently confirms §3.2 from Epic's own documentation, and §3.9 is the same failure seen in a capture.

3.8.3 404 or 500 at authorize ​

The request URL may be too long. Epic's advice is to shorten the state parameter — and that advice has a floor you must not cross. state requires at least 122 bits of entropy, which is about 21 base64url characters; shortening below that breaks the CSRF protection state exists for. If your state is long enough for Epic's advice to apply, the length is almost certainly application data stored inside it, which SMART advises against anyway: move that data server-side, or use Epic's POST-based authorization, and leave the entropy alone. §2.4 has the full argument.

3.8.4 Token endpoint, 400 invalid_request ​

Use POST not GET, application/x-www-form-urlencoded, content in the body, and no ? after /token.

3.8.5 Token endpoint, 400 invalid_client ​

Client authentication invalid; for JWT auth see Epic's JWT troubleshooting, otherwise re-upload the client secret or public key and wait for the sync.

3.8.6 Token endpoint, 400 unauthorized_client ​

Backend OAuth integrations require community-member setup; this step is auto-completed in the sandbox, which means it cannot reproduce in the sandbox and will appear only at a customer.

3.8.7 "This content cannot be displayed in a frame." ​

Epic notes this may be hiding an OAuth 2.0 error or a login page: Interconnect — Epic's integration server, which serves these endpoints — sends an error page or a login page, and because it refuses to be framed the browser shows this message instead of whatever Interconnect actually said. This is the clearest case for inspecting traffic rather than trusting the message: the error the developer sees is not the error that occurred.

There is a second branch Epic's note does not cover, and it is the one you can fix yourself: your own app refusing to be framed. An app embedded in the EHR is inside an iframe, so an X-Frame-Options: DENY or SAMEORIGIN header, or a frame-ancestors CSP directive that does not permit the EHR's origin, produces a blank or blocked frame that looks identical to the vendor case. Check your own response headers before concluding the problem is upstream — and note that these headers are often set by a framework or a reverse proxy rather than by code you wrote.

3.8.8 API call 401 and NO-PATIENT-FOUND ​

When searching by an ID such as MRN, the ID Type in Epic needs a populated descriptor. Epic files this under 401 rather than 403, which matters if you are routing on status code.

Epic also advises: "It's helpful if your app is able to log the response body, since Epic returns detailed error messages for most APIs." That is good advice and it needs a caveat the source does not give. A FHIR response body from a real system is PHI, and an error response may contain patient identifiers. Logging response bodies wholesale is fine against a synthetic sandbox and is a disclosure incident waiting to happen against a customer. Log them behind a flag that is off in production, redact identifiers, or keep the logs under the same controls as the records themselves.

3.9 invalid_grant, "Failed to redeem a launch token" — issued for a different client ​

Observed first-hand 2026-11-02, and not found in any Epic documentation page or community thread consulted for this document. The authorize endpoint redirects back to the app with:

error=invalid_grant
error_description=Failed to redeem a launch token for an authorization code

The cause in this case was mundane: the authorize request carried a client_id for one app while the launch token had been minted for another. Epic's test launcher issues the launch token against whichever app is selected in it, so a launch URL and a hardcoded client ID can drift apart without anything looking wrong.

What makes it worth a section is that the traffic answers it immediately. Epic's launch token is a JWT, and its payload is readable without any key:

json
{
  "aud": "urn:oid:1.2.840.114350.1.13.0.1.7.3.688884.100",
  "client_id": "<the app the launch was actually issued for>",
  "epic.tokentype": "launch",
  "exp": "<iat + 300>",
  ...
}

Decode it, compare client_id against the client_id in the authorize request, and the mismatch is visible in seconds. Guessing instead sends a developer to §3.3 to wait out a sync that was never the problem — which is exactly the failure the "wait a day or two" folklore produces.

Two incidental facts the same payload establishes: the launch token carries "epic.tokentype": "launch", distinguishing it from the authorization code Epic issues at the next step ("epic.tokentype": "code"), and its lifetime is 300 seconds (exp minus iat). A launch token that has been sitting in a terminal for ten minutes is already dead, which is its own small class of confusing failure.

This is the premise of this document in its purest form: the information needed to diagnose the failure is already on the wire, in plain base64, and nothing in the error message points at it.

One decoding caveat, because it bites people. These are base64url, not base64: - and _ replace + and /, and the padding is stripped. A browser's atob() throws on them, and a naive base64 -d may too. Translate the alphabet and re-pad to a multiple of four first. This is an open issue against at least one SMART client library, so it is not only a hand-rolling problem.

3.10 Failures that produce no traffic at all ​

The hardest case in this document is the one it cannot show you, and saying so plainly is more useful than pretending otherwise.

If the EHR never navigates the browser to your launch URL, there is no request, no redirect and no error — only an app that does not start, or a spinner that never resolves. No amount of inspection at your end will diagnose it, because the failure happened entirely upstream.

Reported causes, all configuration rather than code: the app's host not being on the EHR client's browser allowlist; the integration record being configured with the wrong launch type; and a launch pointed at a URL that no longer exists. These need the customer's own IT team, their logs, and in Epic's case usually their Epic representative.

The diagnostic value is in telling the two cases apart quickly. An empty outbound capture is normal before step 3 — see §2. An empty inbound capture means nothing arrived, and that is this section. A listener on your launch URL that logs every request, including ones it cannot handle, is the cheapest way to know which situation you are in, and it is worth having in place before you need it.

3.11 CORS, and where it stops being your problem ​

§2.3 and §2.6 record that Epic's sandbox returns permissive CORS headers, so a browser-only client can redeem a code and call the API from page JavaScript. That is an observation about one sandbox and should not be read as a guarantee.

A customer environment may differ: these headers are configurable, sit behind infrastructure the vendor does not control, and nothing in the SMART specification requires a particular answer — the conformance section says nothing about CORS at all. So a browser-only app can work perfectly against the sandbox and fail at the first customer, with a browser console error rather than an OAuth error, which puts it outside everything else in this section.

There is no client-side fix. Options are a server-side token exchange — which also means becoming a confidential client, with the key management that implies — or asking the customer to permit the origin. Both are architectural, so the time to find out is before the architecture is settled, not at the first deployment.


4. What to do with this ​

Before step 1: your capture is a credential store, and against a real system it is also a medical record. Everything this document redacts, a live capture contains in the clear — authorization code, access token, id_token, PKCE verifier, launch token, session cookies — and against a customer environment the FHIR responses are PHI.

That matters because the next move after capturing is so often to paste it into a forum thread asking for help. The threads cited in §3 contain exactly that. Redact before the capture leaves your machine, prefer a synthetic sandbox for anything you intend to share, and treat a HAR file with the care you would give a password manager export. An access token with an hour left on it is a working credential, not a diagnostic artifact.

Then:

  1. Capture your own launch. Any HTTP proxy or your browser's DevTools will do — but note the caveat: DevTools cannot see a token exchange performed by your server, so a server-side app needs a proxy or its own request logging to capture step 5 at all.
  2. Walk the seven steps and find the first one that differs from this reference. Order matters: an early difference explains later ones, and chasing a step-5 symptom caused by a step-4 parameter is the most common way to lose an afternoon.
  3. If steps 2–4 match exactly and you still fail, do not simply wait. An exact match rules out less than it appears to — §3.9 is a launch whose request parameters all match, and which fails on a client_id that appears only inside the launch token, not in any parameter. Epic's own checklist adds further causes that survive an exact match. Check §3.9 and §3.8 first; §3.3's "wait for the sync" is the answer only once those are excluded, and it is bounded in minutes rather than days.
  4. If the launch succeeds but FHIR calls fail, compare granted scope against requested scope — §2.5 for the capture, §3.7 for the failure mode. This is the highest-yield single check in the document.
  5. If there is no traffic at all, go to §3.10. A launch that never reaches your app produces nothing to diff, and no amount of inspection at your end will reveal why — but §3.10 lists the known causes, all of them upstream configuration, and tells you how to distinguish "nothing outbound yet", which is normal before step 3, from "nothing arrived", which is this case.

Two things to verify that a capture cannot show you, so they belong in this list rather than in the traffic: that you validate the id_token rather than decoding it (§2.5), and that you allowlist iss rather than trusting it (§2.2).


5. Working against Epic's sandbox ​

  • The sandbox is refreshed every Sunday at approximately 8:00 PM Central Time; anything written during the prior week is erased, and connecting near that time produces intermittent errors. Do not start on a Sunday evening.
  • Epic ships major versions quarterly — February, May, August, November — and the sandbox is upgraded within a few weeks of each, typically unavailable for about a day.
  • Allow for the sync delay in §3.3 after any change to the app record.

6. What differs at a customer environment ​

This section bounds what a sandbox-derived reference can honestly claim. Epic is explicit that some things cannot be tested on its public sandbox at all and require a customer's non-production environment: non-OAuth2 launches (the sandbox is "exclusively configured with an OAuth2 security policy"), interface testing, MyChart EHR launch build, and mobile launches via MyChart, Haiku, Canto and Rover (Epic Troubleshooting). Nothing in this document was captured against a customer environment, so treat every observation here as sandbox behavior until you have checked it where you are deploying. §3.11 is the concrete case: the permissive CORS header that makes a browser-only client viable in the sandbox is not guaranteed anywhere else.

The reverse also bites. unauthorized_client on backend OAuth (§3.8.6) is auto-completed in the sandbox and therefore cannot reproduce there — it will appear for the first time at a customer.

Epic's customer environment names, useful context when someone cites one at you: POC (proof of concept, API connectivity not always configured), TST (where most app testing happens), PRD, SUP (a nightly copy of production), RPT (near-real-time reporting copy).


About this document ​

The captures were produced with a small open-source Node harness against Epic's public sandbox, which contains only synthetic data. Traffic was read with Wirescry, an HTTPS-intercept tool we build — the same captures are reproducible with any intercepting proxy or with your browser's DevTools, and the document notes where each of those can and cannot see a given step.

Corrections are welcome. If a claim here does not match what your Epic instance does, that is worth knowing: several claims in this document were wrong when first written and were fixed by checking them against primary sources. Get in touch.