Why Your Patient-Facing App Needs OAuth 2.0 (and Where It Goes Wrong)
Technology Blogs

Why Your Patient-Facing App Needs OAuth 2.0 (and Where It Goes Wrong)

Yash Kalange
Trainee Software Engineer

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.

Who is who in an OAuth 2.0 ExchangeThe 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:

  1. 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.
  2. The user logs in on the authorization server’s own page, so your app never sees the password.
  3. The user approves a consent screen listing what you asked for.
  4. The server redirects back to your redirect_uri with a short-lived authorization code.
  5. Your backend POSTs that code to the /token endpoint, along with the code_verifier, and gets an access token, often with a refresh token.
  6. Your app calls the API with the bearer token.

The authorization code flow, end-to-endThe 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:

token response
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.

Two ways your app gets launched

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.

Reading a SMART scope without guessing

ScopeWhat it gets you
patient/*.rsEverything about one patient. Easy to ask for, hard to defend.
patient/Observation.rsEvery Observation for that patient: labs, vitals, smoking status, the lot.
patient/Observation.rs?category=laboratoryLab results only. Usually the one you actually want.
user/Patient.rsWhatever the signed-in user can see, which for a clinician can be a whole panel.
launch/patientAsks for patient context in a standalone launch.
offline_accessGets you a refresh token that outlives the session. online_access does not.
openid fhirUserIdentity: 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.

What PKCE actually doesWhere 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 seeWhat it usually means
redirect_uri mismatchThe 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 /tokenWrong client_id, or the wrong client authentication method. Some servers want the secret in a Basic header, others want it in the body.
invalid_grantThe 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_scopeYou 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 fineWrong aud at /authorize, a token minted for a different FHIR base URL, or an expiry you did not handle.
403 with a valid tokenThe 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.
Yash Kalange

Yash Kalange

Trainee Software Engineer

Connect Now

Yash is an aspiring software engineer beginning his journey in the world of development. As a trainee, he is actively building his foundation in software engineering, eagerly learning new technologies and honing his coding skills. Driven by curiosity and a strong desire to grow, he is passionate about transforming ideas into functional solutions and is committed to evolving into a well-rounded developer.

Share This Blog

Read More Similar Blogs

Let’s #Transform Healthcare,# Together.

Partner with us to design, build, and scale digital solutions that drive better outcomes.

Location

Global Tech Teams LLC, 525 Washington Blvd, Industrious at Newport Tower, Jersey City, NJ 07310, United States.

Contact

+1 408 786 5974
contact@mindbowser.com
BOOK A QUICK CONSULTATION

Have a Healthcare Project in Mind?

Let’s discuss your goals, workflows, and next steps in a focused consultation call.

Calendar icon Schedule a Call

Contact form