At some point almost every backend team working with an EHR runs into the same wall: you need to pull patient data on a schedule no clinician sitting at a browser, no login screen, just a job that wakes up, fetches records, and writes them somewhere useful. Standard SMART on FHIR, the flavor most people learn first, assumes a human is present to click “Allow.” That doesn’t work for a background job.
This is the exact problem SMART Backend Services was built to solve, and this post walks through how the authentication flow actually works, what the moving pieces are, and the specific things that tend to trip people up when they implement it for the first time.
The Problem We Needed to Solve
A background integration needs to talk to an EHR’s FHIR API on its own schedule nightly batch pulls, queue-driven jobs, whatever the trigger is. There’s no browser session, no user typing a password, and often no human anywhere near the request when it fires.
Regular SMART App Launch is built around a user: redirect them to the EHR’s login page, they authenticate, they approve a consent screen, and the app gets a token tied to that session. That flow simply has no place to plug in when the “user” is a cron job or a task queue consumer. You can’t redirect a server process to a login form and wait for someone to click through it.
What you actually want is closer to how two systems normally trust each other in the backend world: the app proves its own identity with something it holds privately, and the authorization server hands back a token scoped to what that app is allowed to do no session, no browser, no human step at all.
Understanding SMART Backend Services
SMART Backend Services is the part of the SMART on FHIR spec that covers exactly this case: system-to-system access, no user in the loop.
The core difference from user-facing SMART apps is what proves identity. In the interactive flow, identity is established through a login + consent screen and the token represents a user’s authorization. In Backend Services, the application itself is the trusted party. It proves who it is using a private key it holds, and the token represents the application’s own authorization to access data not any particular person’s.
Practically, that means:
- No redirect, no login page, no consent screen at request time.
- Trust is established once, out of band, when the app is registered with the EHR (its public key or JWKS URL is registered ahead of time).
- Every subsequent authentication is the app cryptographically proving “I am who I say I am” using a private key only it has.
- Tokens are short-lived and get refreshed automatically by the backend there’s no session to keep alive, just a token to renew before it expires.
This is what makes it fit background jobs and pipelines: the auth step is just another function call in the code path, not a UI interaction.
How the Architecture Works
At a high level, there are three players: our backend, the EHR’s authorization server, and the EHR’s FHIR server. The backend proves itself to the authorization server, gets a token, and uses that token against the FHIR server.

Nothing in this diagram involves a browser or a person. Steps 1 through 3 happen entirely inside the backend and the authorization server; step 4 is a normal authenticated HTTP call once the token is in hand.
Authentication Flow, Step by Step
1. The backend decides it needs to call the FHIR API. This could be the start of a batch job, or a cache miss on an existing token.
2. It builds a JWT assertion a small signed document that says “this is me, and this request is fresh.”
3. The JWT carries a specific set of claims (iss, sub, aud, jti, iat, exp) that identify the client, target the right audience, and prevent replay. More on each of these below.
4. The JWT is signed with the app’s private key, using an asymmetric algorithm like RS384. The EHR only ever sees the corresponding public key (registered ahead of time), never the private key itself.
5. The backend POSTs the signed assertion to the authorization server’s token endpoint, along with the client_credentials grant type and the requested scopes.
6. The authorization server validates the JWT checks the signature against the registered public key, confirms the claims (audience, expiry, issuer) line up, and confirms the app is allowed the scopes it’s asking for.
7. If everything checks out, it returns a short-lived access token (typically valid for under an hour).
8. The backend attaches that token as a Bearer credential on every subsequent FHIR API call, until it expires and step 1 repeats.
Each step exists for a reason: the JWT proves identity without ever transmitting a shared secret over the wire, the short expiry (exp) limits how long a stolen assertion would be usable even if intercepted, and the unique jti stops a captured assertion from being replayed a second time.
Sequence Diagram

Key Components
| Component | What it does | Simple explanation |
|---|---|---|
| Client ID | Identifies the backend application to the EHR | Like a username for the app, registered ahead of time |
| Private Key | Signs the JWT assertion | Proves the request really came from us, without sending a password |
| Public Key / JWKS | Lets the EHR verify our signature | The EHR’s copy of our “signature sample,” registered during onboarding |
| JWT Client Assertion | The signed proof-of-identity document | A short-lived, tamper-evident note saying “this is us, right now” |
| iss / sub claims | Identify the client in the JWT | Both set to the client ID, the app is both the issuer and the subject of the claim |
| aud claim | States who the JWT is meant for | The token endpoint URL, stops the assertion being replayed against a different server |
| jti claim | Unique ID per JWT | Prevents the same signed assertion from being reused (replay protection) |
| exp claim | Expiry timestamp on the JWT itself | Keeps the assertion valid only for a few minutes, not the token’s own lifetime |
| Access Token | The credential used for actual API calls | A short-lived pass, earned by presenting the signed JWT |
| Scopes | What the token is allowed to access | Boundaries on which FHIR resources/operations the token can touch |
It’s worth being precise about something that confuses people early on: the JWT assertion and the access token are two different, short-lived things. The JWT proves identity to get a token; the access token is what you actually attach to FHIR calls. Mixing these up e.g., trying to use the client assertion itself as a Bearer token is a common early mistake.
Need Help Implementing This Against a Specific EHR? Talk to Our FHIR Integration Team
Implementation Approach
Rather than dropping code inline throughout, here’s the shape of how this gets organized in a backend:
Build JWT assertion (iss, sub, aud, jti, iat, exp)
↓
Sign JWT with private key (RS384)
↓
POST assertion + grant_type=client_credentials to /token
↓
Receive access token + expiry
↓
Cache token until near-expiry
↓
Attach token as Bearer header on FHIR callsA few design decisions matter more than they look at first glance:
- Private key handling is isolated from the rest of the auth code. The key is fetched from a secrets manager at the moment it’s needed, not baked into config or checked into source. The signing logic doesn’t care where the key came from, it just takes a PEM string and an algorithm.
- The signing algorithm is a first-class configuration value, not a hardcoded constant. The header (alg) and the actual crypto operation used to produce the signature have to agree, or the EHR’s signature check fails with a generic, unhelpful error. Deriving the crypto algorithm from the same configured value that populates the JWT header, instead of hardcoding one and configuring the other separately, closes off an entire category of “signature invalid” bugs.
- Tokens are cached and reused until they’re close to expiring, rather than minting a new JWT and hitting /token on every single API call. This matters because the token endpoint is itself typically rate-limited, and burning a token exchange per request wastes budget that should go to the actual data calls.
- Concurrent requests for the same token collapse into one refresh. If multiple workers wake up at roughly the same time and all discover their cached token is missing or expired, only one of them should actually hit the token endpoint, the rest wait on that same in-flight request rather than each firing their own. Otherwise a cold-start burst turns into a burst of redundant /token calls, which is exactly the kind of thing a rate limit notices.
- The response from the token endpoint is treated as sensitive. It’s logged by shape (status code, presence of fields, error codes) rather than by content, since the body can carry the live access token itself.
Real-World Example
Say a backend job needs to pull a patient’s recent encounters from the EHR as part of a nightly sync, with nobody watching it run:
- The job checks whether it already has a valid cached token for this credential. If not, it builds and signs a fresh JWT assertion.
- It exchanges that assertion for an access token at the EHR’s token endpoint.
- It calls the FHIR Encounter search endpoint with the access token as a Bearer credential.
- It processes whatever comes back parses the FHIR bundle, extracts the resources it needs, and stores them.
The interesting part is that from the job’s perspective, authentication is invisible. It asks a token provider for a token, gets one back (cached or freshly minted), and moves on. All the JWT-building and signing complexity lives behind that one call.
Challenges We Faced
- JWT claims and signing mismatches. The most common failure mode in this kind of flow is a JWT that’s structurally valid but gets rejected anyway because the aud doesn’t exactly match what the authorization server expects, or the algorithm declared in the header doesn’t match the algorithm actually used to produce the signature. These errors tend to come back as a flat “invalid_client” or “invalid signature” with no indication of which claim was wrong, so root-causing them means checking every claim against the spec and the EHR’s own documentation line by line, not guessing. The fix that generalizes well: derive the signing algorithm used at the crypto layer from the same config value that populates the JWT header, so the two can never drift apart.
- Practice/tenant-specific token endpoint configuration. In a multi-tenant integration, each practice or organization can have its own token URL, client ID, and rate limit. Treating these as one global, hardcoded configuration works fine until the second tenant onboards then it breaks in a way that’s easy to misdiagnose as a JWT problem when it’s actually a routing problem. The fix is to resolve this configuration per tenant at request time rather than assuming a single static value.
- Rate limits on the token endpoint itself. It’s easy to assume rate limiting only applies to the FHIR data calls and forget that the token endpoint shares the same budget. Under load many workers starting around the same time that produces a burst of token requests that eats into the budget meant for actual data retrieval. Two things address this: caching tokens aggressively so refreshes are rare, and collapsing concurrent refresh attempts into a single in-flight request instead of letting every caller mint its own.
Key Learnings
- The JWT assertion and the access token solve two different problems one proves identity, the other authorizes API calls and conflating them causes confusing bugs.
- A JWT that looks well-formed can still fail validation for reasons that have nothing to do with its structure: audience mismatches and algorithm mismatches are the two most common, and both fail with vague error messages.
- Configuration correctness (endpoints, algorithms, per-tenant values) is just as important as the cryptography a perfectly signed JWT sent to the wrong audience still gets rejected.
- Treat the token endpoint as a rate-limited resource, not just the FHIR API token minting should be cached and deduplicated, not called on every request.
- Anything that can carry a live credential token responses, cached tokens needs to be handled and logged carefully; log shape and metadata, not content.
Conclusion
SMART Backend Services gives backend systems a way to authenticate to an EHR that doesn’t need a human in the loop the application proves its own identity with a private key, gets a short-lived token, and uses it against the FHIR API. Getting it right isn’t really about writing more code; it’s about getting the JWT claims exact, keeping the signing algorithm and header in agreement, treating token endpoints as rate-limited infrastructure, and caching tokens instead of re-authenticating on every call. Once those pieces are solid, the auth step disappears into the background exactly the way it should invisible, automatic, and out of the way of the actual integration work.








BLOGS
NEWSROOM
CASE STUDIES
WEBINARS
PODCASTS
ASSET HUB
EVENT CALENDAR 


















