Open source · runs on your machine
Why does this SMART on FHIR login fail?
EHRs answer almost every client problem with the same invalid_client. smart-doctor checks everything you can check from your side, runs the real login once, and ranks the likely causes in plain English.
npx smart-doctor init- Keys and secrets stay local
- No account, no telemetry
- Backend services and user sign-in
- Key passed
- Discovery passed
- Clock passed
- JWKS skipped
- Assertion passed
- Token failed
- FHIR not reached
- Discovery596ms · SMART config
- Clock−1s skew
- Assertion240s until exp · ES384
- Token401invalid_client
A real run against the public SMART launcher, with a mistyped kid.
Complaint
One error, dozens of causes
Servers say as little as possible on purpose, so an attacker learns nothing. You learn nothing either. smart-doctor works out the rest from your side of the connection.
What the server says
{"error": "invalid_client"}
That’s all. It could be any of these, and more:
What smart-doctor tells you
-
Your kid is not in the JWKS the server knows aboutvery likely
The server looks up the kid from your header in your registered JWKS and does not find it.
server said “None of the keys found in the JWKS have kid equal to sandbox-typo”
Publish the key with that kid, or sign with a kid that is published.
Ruled out on the way
- ES384 key loads, kid set
- aud = token endpoint
- iss = sub = client_id
- exp 240 s, jti unique
- clock −1 s
How it works
Your side of the login, examined
smart-doctor sends the server exactly what your app would send, and keeps everything else on your machine.
Examination
Both kinds of login
Backend services
client_credentials · private_key_jwt or client secret
- Your key or secret loads, and its algorithm fits
- SMART configuration and the token endpoint
- Your clock against the server’s
- JWKS reachable, has your
kid, and the published key is yours - No private key material in the public JWKS
- The assertion:
aud,exp≤ 5 min,jti,kid,iss=sub - The real token request and the scopes granted
- Optional FHIR call, status code only
User sign-in
SMART App Launch · authorization_code + PKCE
- Authorize endpoint, PKCE S256 and capabilities
- Your redirect URI, exactly as registered today
- Sign-in, then
stateandisson the way back - Code exchange as a public client, with a secret or with a key
- Patient context
id_tokensignature,audandfhirUser- Refresh token
- Optional FHIR call, status code only
No registration changes.With a http://localhost redirect URI, smart-doctor catches the redirect itself. For anything else you sign in, land on your company’s page, and paste its address back.
Diagnosis
Ranked causes, with the evidence
Every cause comes with why it happens, the evidence smart-doctor found, and the exact next step.
Rule out what can be checked
Keys, JWKS, the assertion, your clock and the redirect URI, all verified locally first.
Read the server’s own words
Including the non-standard spellings vendors really send, like
Invalid Grantandinvalid_code.Add vendor knowledge
Hints for Epic, Oracle Health (Cerner), OpenEMR, athenahealth, each from a public source.
Name what it can’t see
When everything local passes: the server-side causes left, and how to confirm them.
- causes explained
- 57
- server messages recognised
- 18
- practice failure modes
- 18
- breakages checked on a public server
- 16
Treatment
Run it in two minutes
Practise first
A web view with a practice EHR that fails on purpose. No account needed.
npx smart-doctor ui --demoPoint it at your server
A few questions, the rest discovered, saved as a profile that never holds a secret.
npx smart-doctor initShare the result safely
The report masks the client ID, tokens and secrets: ready for a ticket, a vendor or an AI.
npx smart-doctor check my-profile --report
No Node.js?
Standalone binaries for macOS, Linux and Windows on the releases page, with checksums.
Nightly monitoring
A GitHub Action catches expiring keys and registration drift before production does.
Docker
A small image for CI pipelines: mount the folder with your profile and run check.
AI agents
A Claude Code plugin whose skill passes keys by path only and shares --report output.
Precautions
Built to be trusted with keys
| Item | Leaves your machine | How |
|---|---|---|
| Private key | never | Signs one test assertion in-process. |
| Client secret | token endpoint only | Read from an environment variable, never a flag. |
| Tokens and codes | where your app sends them | Masked in every trace and report. |
| Patient data | never read | The FHIR call records the status code only. |
| Usage data | none | No account, no cloud, no telemetry. |
- Masked at the sourceEvery HTTP exchange is redacted inside the engine before anything can display it, and tests enforce it.
- A local web view, locked downBinds to 127.0.0.1, needs a per-session token, strict Content-Security-Policy, no third-party requests.
- Read every line that runsPlain JavaScript with no build step, MIT licensed. Security model.
Next time it says invalid_client, ask smart-doctor.
Free and open source. Run it once against the practice EHR, then against yours.
npx smart-doctor init