Auth: OAuth2 · OIDC · SMART
Three layers people constantly conflate: OAuth2 grants access, OIDC proves identity, SMART on FHIR adds the clinical context. Getting the distinction right is most of health API security.
In one line
OAuth2 answers what may this app do? OIDC answers who is this person? SMART answers which patient are we talking about, and in what context? They stack, and conflating them is the most common auth mistake in health IT.
The distinction that matters
The one-sentence version people should tattoo somewhere:
OAuth2 is authorisation, not authentication.
OAuth2 gives an app a token that says "you may read this person's lab results." It does not tell you who the person is. Using an OAuth2 access token as proof of identity is a classic and genuinely dangerous error — the token proves someone granted access, not who is holding it.
That gap is exactly why OIDC exists. It's a thin layer on top of OAuth2 that adds an ID token — a signed JWT that makes claims about the user's identity. OAuth2 gives you a valet key; OIDC tells you whose car it is.
The flow, briefly
The modern default is authorisation code with PKCE:
- App redirects the user to the authorisation server.
- User authenticates there — the app never sees the password, which is the entire point.
- Server redirects back with a short-lived code.
- App exchanges the code (plus its PKCE verifier) for an access token.
- App calls the API with the token.
PKCE exists because mobile and browser apps cannot keep a secret — anything shipped to a device is public. It binds the code to whoever started the flow, so an intercepted code is useless. It's now recommended for confidential clients too. Use it.
Never use the implicit flow. It returns tokens in the URL fragment, where they land in browser history, logs, and referrer headers. It's deprecated and it was always a compromise.
What SMART adds
SMART on FHIR takes OAuth2 + OIDC and adds the thing health actually needs: clinical context.
An app launched from an EHR needs to know more than "you may read Observations." It needs to
know which patient is on screen right now. SMART's launch context supplies exactly that —
patient, encounter, user — so the app opens on the right chart rather than asking the
clinician to search for a patient they're already looking at.
And SMART scopes are FHIR-shaped:
patient/Observation.read — read Observations for the one patient in context
user/Patient.read — read any Patient this user may see
system/Observation.read — no user at all; backend service
That patient/ vs user/ vs system/ prefix carries real weight. patient/Observation.read
is narrow. user/Observation.read inherits everything that clinician can see, which in a
hospital may be everyone. Granting the second when you meant the first is a scope error that
looks identical in the code and is enormously different in exposure.
Backend Services is the no-user variant — how Bulk Data authorises a population export. There's nobody to click "allow", so the entire trust sits in a client credential. Treat it accordingly.
Where health teams get burned
- Treating the access token as identity. Covered above; still happens constantly.
- Not validating the ID token. Signature, issuer, audience, expiry, nonce. A JWT you didn't verify is a string someone gave you.
- Over-broad scopes because the narrow one was fiddly to implement. Nobody notices until an audit.
- Long-lived refresh tokens with no revocation path. A consent withdrawal that doesn't kill the refresh token didn't withdraw anything.
- Forgetting that authorisation ≠ permission. A valid token says the auth server agreed. Whether this clinician should see this patient's psychiatric notes is a decision your application still owns.
That last one is the real lesson: OAuth2 tells you the door was unlocked. It does not tell you the person should be in the room.