If you’ve built anything that touches an EHR, you’ve hit OAuth 2.0, probably while staring at a redirect URI mismatch error at an hour when no reasonable person is awake. Here’s what the protocol does, why healthcare leans on it, and the places it bites.
Most of what follows is plain OAuth and applies anywhere. The parts that are specific to healthcare are the scopes, the launch context, and the paperwork behind both.
The Short History
OAuth 1.0 let a third-party app act on your behalf without your password, but it made you sign every API call. You built a “signature base string” by sorting and percent-encoding request parameters in an exact order, then hashed it, usually with HMAC-SHA1. One stray encoding difference and you got a 401 with no useful detail. It also assumed the client could keep a secret and do crypto reliably, which falls apart on a mobile or browser app with nowhere safe to hide a secret.
OAuth 2.0 dropped request signing and leaned on TLS instead. The client gets a bearer token and puts it in an Authorization: Bearer header. Eran Hammer, who edited the early drafts, pulled his name from the spec, arguing it pushed too much security burden onto implementers. He had a point about bearer tokens. But it was far easier to build against, and 2.0 is what shipped everywhere.
The security work did not stop when the spec froze. Most of what makes OAuth 2.0 safe today lives in later RFCs and profiles: PKCE, token revocation, the security best current practice document. Treat the core spec as the frame and the extensions as the parts you actually implement.
The Four Roles, and Who Runs Them
Four roles: the resource owner (your patient), the client (your app), the authorization server that issues tokens, and the resource server that holds the data and checks tokens. In most EHR deployments one vendor runs the last two.
The split matters more than the names. Your app never sees the password, so a breach on your side does not hand over an account. The EHR never has to trust your login code, so it can hand out narrow access without auditing your codebase. Everything else in the protocol exists to keep that arrangement honest.
One more distinction worth getting right at registration time. A confidential client can keep a secret, which in practice means a server you control. A public client cannot, which means a mobile app or a single-page app, because anything shipped to a device can be pulled apart. Register as what you actually are. Registering a public client as confidential and shipping the secret in your bundle is a finding waiting to happen.
The Flow You Actually Use
For anything user-facing, you want the authorization code grant:
- Your app redirects the browser to the authorization server’s /authorize endpoint with your client_id, redirect_uri, requested scope, a random state, the aud (the FHIR base URL you intend to call), and a code_challenge.
- The user logs in on the authorization server’s own page, so your app never sees the password.
- The user approves a consent screen listing what you asked for.
- The server redirects back to your redirect_uri with a short-lived authorization code.
- Your backend POSTs that code to the /token endpoint, along with the code_verifier, and gets an access token, often with a refresh token.
- Your app calls the API with the bearer token.
The two-step dance (code first, then token) exists because the code travels through the browser, where it can leak into logs or a Referer header, while the token exchange happens server to server. The code is single-use and usually expires in under a minute. Scopes say what the token can do; refresh tokens get you a new access token without dragging the user back to the login screen.
Two parameters carry more weight than their size suggests. The state is yours: generate it randomly, tie it to the session, and compare it when the redirect comes back. If it does not match, throw the response away, because you are looking at someone else’s authorization attempt landing on your callback. The aud has to be the FHIR base URL you plan to call, exactly as the server publishes it. Getting it slightly wrong tends to succeed at /authorize and fail later with a 401 that points nowhere near the real cause.
What Actually Comes Back With the Token
The token response is where SMART adds its own fields, and it repays reading carefully:

The scope in the response is the one you have. The scope in your request was a wish. Servers routinely grant less than you asked for, either because the registration does not allow it or because the patient unticked something on the consent screen, and they do it without an error. If your app branches on features, branch on the granted scope, not on what you sent.
The patient field is the FHIR id for the person who just authorized you. Use it. Do not search by name and date of birth to work out who they are, and do not let a patient id from a URL or a stored session override what the token says. Note the absence of a refresh_token in a response too, because that decision is made by the server and you cannot argue with it at runtime.
Two Ways Your App Gets Launched
SMART defines two launch modes, and mixing them up is a common early detour.
EHR launch is for apps that open inside the chart. The EHR calls your launch URL with iss and launch on the query string, you pass the launch value straight back into /authorize with the launch scope, and the token comes back already scoped to the patient and often the encounter the clinician was looking at.
Standalone launch is what most patient-facing apps use. Nobody hands you context, so you ask for it with launch/patient and let the EHR work out which record belongs to the person logging in.
Both paths start with the same discovery step. Fetch the FHIR base URL plus /.well-known/smart-configuration and you get the authorization and token endpoints, the supported scopes, the PKCE methods, and the capabilities the server claims. Read those at runtime and cache them. Hardcoding endpoint URLs per vendor works right up until one of them moves.

Need help hardening your app's OAuth 2.0 / SMART on FHIR implementation? Talk to our Team
Why Healthcare Cares
In the US, the 21st Century Cures Act and the ONC’s 2020 Final Rule made standardized API access a requirement. Certified health IT has to expose patient data through FHIR (Fast Healthcare Interoperability Resources) Release 4 and support the SMART App Launch framework. The rule also carries information blocking provisions, which means a vendor refusing to let a patient’s chosen app connect needs a legal exception, not just a policy preference.
SMART on FHIR is the profile that ties it together. It came out of Boston Children’s Hospital and Harvard (Josh Mandel and Kenneth Mandl), and it uses OAuth 2.0 with FHIR-specific scopes like patient/Observation.read. This is what lets an outside app plug into Epic, Oracle Health (Cerner), and others through one model instead of a contract per vendor. Apple Health Records connects to hospitals this way, and Epic runs a developer program (formerly App Orchard, now Connection Hub). Under HIPAA’s right of access, a patient can direct their data to an app they choose, and OAuth makes that consent explicit and revocable.
Worth knowing: once a patient sends their data to an app they picked, HIPAA usually stops applying to what you do with it, and the FTC’s Health Breach Notification Rule starts. Being outside HIPAA is not the same as being unregulated.
Reading a Scope Without Guessing
Scopes are the part teams skim and then regret. The grammar is small enough to learn in one sitting.

| Scope | What it gets you |
| patient/*.rs | Everything about one patient. Easy to ask for, hard to defend. |
| patient/Observation.rs | Every Observation for that patient: labs, vitals, smoking status, the lot. |
| patient/Observation.rs?category=laboratory | Lab results only. Usually the one you actually want. |
| user/Patient.rs | Whatever the signed-in user can see, which for a clinician can be a whole panel. |
| launch/patient | Asks for patient context in a standalone launch. |
| offline_access | Gets you a refresh token that outlives the session. online_access does not. |
| openid fhirUser | Identity: who logged in, as a reference to a Patient or Practitioner resource. |
The v1 spelling used .read and .write. The v2 spelling uses letters for create, read, update, delete and search, and adds the query filter on the end. Servers vary in which they accept, and a few accept both. The discovery document tells you which, so check it instead of guessing and reading the error.
Where It Goes Wrong
Bearer Tokens Are Bearer Instruments
Whoever holds one can use it, no signature required, which is why token theft is the thing to worry about. The mitigations shrink the blast radius. Access tokens stay short-lived, often an hour, with refresh tokens doing renewal. PKCE (RFC 7636) is required for mobile and browser apps: the app sends a hashed code_challenge up front and proves it holds the original code_verifier when redeeming the code, so an intercepted code is useless.
Where the token lives matters as much as how long it lasts. localStorage is readable by any script that ends up on your page, which includes the analytics tag someone added last quarter. On mobile, use the platform keystore and the system browser, not an embedded webview you control, because a webview you control is a webview you could read the password out of, and reviewers know it.
Over-Broad Scopes
Requesting patient/*.read always works and you never touch scope config again, but if your app only shows labs, ask for lab observations. “We only ever had read access to laboratory observations” is a much better sentence in an audit than “we had read access to everything.” SMART App Launch v2 added granular scopes for exactly this.
There is a product argument as well as a security one. The consent screen is built from your scope list, so an app that asks for everything gives the patient a wall of text to approve on the one screen where you most want them to feel comfortable.
Refresh Tokens You Cannot Rely On
Plenty of teams design a background sync, then discover the server never issued a refresh token, or issues one that rotates on every use, or only grants offline_access to confidential clients. All three are normal. Handle expiry as the ordinary path rather than an error case: catch the 401, refresh once, retry once, and send the user back through the flow if that fails. Then test it by expiring a token on purpose, because otherwise the first time you see this code run is in production at three in the morning.
PHI in Places You Did Not Mean to Put It
The unglamorous failure is PHI landing in application logs or a cached URL, so treat tokens like passwords and scrub them. Log the shape of an auth exchange, not its contents: the status code, which fields were present, the error code. Watch the quieter leaks too. Error trackers that capture full URLs, mobile crash reporters that snapshot the screen, and support tools that record sessions will all happily archive a patient’s chart without anyone deciding that they should.
The Errors You Will Actually See
| What you see | What it usually means |
| redirect_uri mismatch | The registered URI and the one you sent differ by something small: a trailing slash, http against https, a port number, an extra query string. Servers compare the whole string, not the shape of it. |
| invalid_client at /token | Wrong client_id, or the wrong client authentication method. Some servers want the secret in a Basic header, others want it in the body. |
| invalid_grant | The code expired, was already redeemed, or your code_verifier does not hash to the challenge you sent. Codes are single use, so retrying the same one is never the fix. |
| invalid_scope | You asked for something your registration does not allow, or you used v2 scope syntax against a v1 server. |
| 401 on a FHIR call with a token that looked fine | Wrong aud at /authorize, a token minted for a different FHIR base URL, or an expiry you did not handle. |
| 403 with a valid token | The token is fine, the scope is not. Compare the granted scope against the call you just made. |
None of these come back with a helpful body. Getting into the habit of diffing your request against the discovery document, parameter by parameter, is faster than reading the message.
When There Is No User at All
Everything above assumes a person is present to approve something. Plenty of healthcare integrations have no such person: nightly extracts, queue workers, population reports. The authorization code grant has nowhere to plug a cron job in.
That case has its own profile, SMART Backend Services, which uses the client credentials grant with a signed JWT instead of a login. The app holds a private key, the EHR holds the matching public key from registration, and the app proves its identity on every token request. Scopes there start with system/ rather than patient/. Different flow, same instinct: prove who you are, get a narrow short-lived token, and keep the credential out of everything that logs.
Where to Start
Don’t hand-roll the flow against a production EHR. Read the SMART App Launch spec for the scope grammar and launch sequence, then test against a sandbox. The SMART Health IT sandbox and Epic’s and Oracle Health’s developer sandboxes all speak the same protocol, so a flow that works against one is close to working against the rest.
A short checklist that saves most of the pain:
- Build the discovery step first and read every endpoint from /.well-known/smart-configuration.
- Register the exact redirect URI you will ship, scheme included, and use a different registration for staging.
- Generate state and code_verifier with a real random source, and verify state on the way back.
- Write down the narrowest scope list your features need before you write the request. Adding one later is a support ticket.
- Read the granted scope from the token response and let the UI follow it.
- Force a token expiry in testing and make sure the refresh path actually runs.
- Use a maintained OAuth or SMART client library. There is no prize for parsing your own JWTs.








BLOGS
NEWSROOM
CASE STUDIES
WEBINARS
PODCASTS
ASSET HUB
EVENT CALENDAR 


















