A Multi-Provider SMART on FHIR Backend: My GSoC 2026 Final Report

August 24, 2026 • 20 min read


This is my Google Summer of Code 2026 final work product. I am Philip Narteh, GSoC 2026 contributor with Data for the Common Good (D4CG), mentored by Luca Graglia. The project is Building a Multi-Provider SMART on FHIR Backend for Patient Health Record Access.

The organization's repositories are private, so the pull requests referenced below are given by number and title and will not open for anyone outside the organization. Nothing here depends on that. Every claim this report makes is evidenced on the page itself, by a diagram, a screenshot, a response captured from the running service, or a number you can hold me to.


The problem this project exists to solve

If you live in the United States, your medical history is not in one place. It is spread across every hospital, clinic, urgent care and lab you have ever walked into, and each of them keeps its own copy in its own system. If you wanted the whole picture tomorrow, in one place, you would have to ask each of them separately and hope they answered.

Data for the Common Good is a group at the University of Chicago that pools health data so researchers can actually use it, and their Pediatric Cancer Data Commons holds the largest harmonized collection of childhood cancer data in the world. For that work to include a patient's own records, the patient has to be able to hand them over, which means something has to go and fetch them from whichever hospital system happens to be holding them.

That something is the backend I spent the summer on. What I inherited was a proof of concept. It could log a patient into Epic's test environment, and once it had done so it threw away everything it had obtained. My mentor Luca Graglia and I spent the summer turning it into a service that keeps what it earns, works against any compliant server and hands the result to a researcher in a shape they can use.

The standards this is built on

The following six terms explain most of the work that follows, and they are worth a sentence each before the story continues.

An EHR (electronic health record) is the software a hospital keeps its charts in. A few dozen vendors dominate the American market, Epic, Oracle Health (formerly Cerner) and athenahealth among others.

FHIR (say "fire") is the agreed format for medical data, the way every email program once agreed on what an email looks like. It breaks a chart into resources, each a JSON object of a known type: a Patient, a Condition for a diagnosis, an Observation for a lab result. The web address a hospital serves its FHIR data from is called an endpoint.

SMART on FHIR is the login layer on top of that format. It is OAuth 2.0 with healthcare rules attached, which is to say it is the "log in with Google" pattern applied to your hospital.

The authorization code flow is how that login works. The app sends you to the hospital's own login page, you approve what is being asked for, the hospital sends you back holding a short-lived code, and the backend trades that code for an access token. The app never sees your password, and you approved exactly what it may read.

PKCE (say "pixie") protects that trade. The backend invents a random secret at the start of the login and sends out only a hash of it. At the token step it presents the original, the server re-computes the hash, and the exchange is refused unless the two agree, so a stolen code on its own is worthless.

Discovery is the part everything here is built on. Every SMART server publishes a small JSON document at a fixed address, /.well-known/smart-configuration, in which it describes itself: its login URL, its token URL, the authentication methods it accepts, whether it wants PKCE or not, etc.

The goals

This is the abstract I registered with the program in the spring, so the work can be measured against its own promises:

D4CG's SMART on FHIR application gives patients autonomy over their Electronic Health Records, enabling them to share records with researchers. The current backend supports only Epic's sandbox environment, discards OAuth tokens after exchange, and uses in-memory state management that cannot scale. This project builds a production-ready multi-provider backend that supports Epic, Cerner, and any generic SMART-compliant provider. It replaces the current in-memory architecture with database-backed token persistence, adds enhanced provider search via the ONC Lantern registry, and delivers a tested, documented, and containerized API.

Every clause of that paragraph is built. Epic and Oracle Health both complete a real login today, and so does a third server neither of them resembles, so "Epic, Cerner, and any generic SMART-compliant provider" is met. The containerized API is done as well: docker compose up on a clean checkout builds the image, starts the database, applies the migrations and serves the API, and it is in review as the final pull request before mentor review. The state can be summarized like this:

BeforeAfter
3 Python files, 435 lines, no tests28 modules, about 5,400 lines, with about 6,800 lines of tests
Epic only, with its login and token URLs written into the codeAny compliant server, with both read from the server at connection time
state_store = {}, lost on every restartPostgres, with OAuth state that expires on its own
Tokens discarded the moment they arrivedStored encrypted, refreshed, rotated, retired, revocable
The audience parameter always pointed at EpicDerived from whichever server is being connected
allow_origins=["*"], no rate limitsLocked to the frontend's origin, with limits on the login and read paths
Raw FHIR, all 61 resource types on every readNormalized, the 10 US Core types first and the rest on request

Why "multi-provider" does not mean one integration per hospital

This is the part that gets misread, so it is worth being direct about. The national endpoint list holds about 70,000 FHIR endpoints. Those are not 70,000 different systems. They are operated by a few dozen vendors, and every one of those vendors implements the same standard.

So the naive reading of "support as many providers as possible", one class per vendor, is both impossible and unnecessary. You do not scale the code with the servers. You scale it with the standard. I wrote a single adapter that reads the server's own description of itself and adapts to what it finds, and there is no class anywhere in the project named after a vendor.

How it works now

Diagram of the SMART on FHIR login flow. A patient clicks connect, the backend reads the hospital's self-description, the patient logs in and approves on the hospital's own page, the hospital returns a one-time code, the backend trades the code plus its PKCE proof for an access token which it stores encrypted, and the patient's records come back.

That is the login. Underneath it, the service is three layers over a database, and the layer in the middle is the whole argument of the project.

Architecture diagram. A patient's browser and the national endpoint list both call into the backend. The backend has three layers: the API a caller talks to, one provider adapter that reads each server's own description of itself, and a normalization layer that reshapes every answer into one US Core shape. Postgres below it holds tokens encrypted at rest, OAuth state that expires, and sessions. The same adapter drives Epic's sandbox, the public SMART Launcher and Oracle Health's sandbox, and any further server is a configuration entry rather than new code.

What I built

One adapter, and the discovery that makes it possible

The first piece to land was the interface every provider has to satisfy, together with the single implementation of it, GenericSMARTProvider. At connection time it fetches the server's /.well-known/smart-configuration and configures itself from whatever comes back. PKCE turns on because the server advertises S256 in code_challenge_methods_supported, not because I decided it should, and the client authentication method is read from token_endpoint_auth_methods_supported in exactly the same way. Both of those choices belong to the server, and the adapter itself carries nothing specific to any vendor. (PR #4)

One small decision inside that mattered more than it looks. The PKCE secret is born when the login starts and is not needed until the token exchange, which is a separate request, so it has to survive in between. Rather than return just a URL, build_auth_url hands back an object carrying the URL and the secret together, which lets the backend put that secret straight into its own database. Had the function returned only the URL, the tempting way to carry the secret would have been to send it along with the user, which defeats the protection entirely.

Somewhere to put what the login earns

OAuth state moved out of a dictionary into Postgres with an expiry, so it survives a restart and works across more than one worker. Tokens are stored encrypted with Fernet through a column type that encrypts on write and decrypts on read, so the rest of the code only ever sees ordinary strings. The encryption key has no default, which means an unset key stops the application from starting instead of letting it run unsafely and quietly. (PRs #8, #9)

The headline fix

The proof of concept completed the entire login correctly, returned {"success": true} to the browser, and dropped the tokens, so every login began from nothing. The callback now persists what it receives. In the same change, the audience parameter stopped pointing at Epic regardless of which server was being contacted and started being derived from the issuer, and the sandbox credentials moved out of the source and into the environment. (PR #10)

Security, and proof against more than one server

CORS moved from "anyone" to the frontend's origin. The login and read paths gained rate limits. Reads became bound to a server-issued session, which closes the hole where changing a number in a URL would have read somebody else's chart. The same adapter was also driven against three structurally different servers in a single test, with PKCE sourced from each one's own discovery document. (PR #14)

When the government feed went down

Before any login can start, a patient has to say which hospital they are with, so the service offers a list. That list comes from LANTERN, a directory of FHIR endpoints published by the US government. Its download URL began returning 404, so I repointed the backend at the GitHub mirror, and then the mirror was bulk-pruned a few days later. The fix that stuck after some deliberation was to serve the newest file actually available and to degrade instead of fail, because the alternative took the provider list down with it. (PRs #12 and #17, plus #18 for the sandboxes that list does not contain)

Making the data usable

Different servers return subtly different structures for the same resource type. The normalization layer parses responses through fhir.resources R4B models and reshapes them into one internal form, so a lab result looks the same whichever vendor it came from, while the untouched original travels alongside it so nothing the server sent is lost. Reads now fetch the US Core mandatory set by default, ten types rather than all sixty-one, with the rest available on request. A resource that fails to parse is skipped with a warning instead of taking down the read. (PR #19)

On top of that sits the API a research consumer actually wants - a patient's normalized resources, a clinical summary grouped into sections, and an OpenAPI document with examples. A patient record is identified by an opaque identifier of ours rather than by the one a provider issued, because a FHIR patient identifier is only unique to a single server, and two servers can hand out the same string for different people. (PR #27)

Keeping a connection alive, and letting it end

An access token lasts about an hour, which means every endpoint stops working shortly after a patient connects. A read now renews an expired token through the adapter before it fetches, stores the rotated refresh token the server hands back, and collapses concurrent reads onto a single refresh. A refresh the server refuses is reported as needing re-authorization rather than as a generic failure. Connections nothing can reach any more are retired on a sweep, and a caller can deliberately disconnect a provider, which revokes at the EHR where one publishes a revocation endpoint. (PR #28)

Asking a server whether it is usable, before sending anyone to it

The national list marks each endpoint as SMART-capable, but that flag is the government's, recorded whenever they last probed it, and the newest file available as I write this is dated November 2025. Trusting it means a patient can pick an endpoint the file still calls healthy and be sent into a login that was never going to work.

GET /providers/endpoint-check asks the server itself instead. Give it an issuer and it reads that server's SMART configuration there and then: a working endpoint comes back with the authorize and token URLs it advertises, and a broken one comes back with the reason it is broken. The server may publish no SMART configuration at all, it may publish one that does not parse, or it may not be reachable from here. That last case matters more than it sounds, because several vendors serving thousands of endpoints refuse an unauthenticated request for their configuration, so "could not confirm" has to read as a warning instead of closing the door completely. (PR #29)

Bringing the whole thing up with one command

Running this used to mean installing Poetry, provisioning a Postgres, generating an encryption key, filling in an environment file and applying the migrations by hand, which is a lot to ask of someone who only wants to see whether a change behaves. docker compose up builds the image, starts the database, migrates it and serves the API.

Two details in there were worth the thought. Migrations run as their own one-shot service that the application waits for, rather than inside the application's own startup. The difference only shows when a migration fails. Run them at startup and the application exits, Docker restarts it, it exits again, and what an operator sees is a container looping with the real cause buried in its logs. Run them separately and the migration is the thing that fails, by name, while the application simply never starts. The health check reads a new GET /health that runs SELECT 1 instead of merely answering, because the database engine connects lazily and a listening process on its own proves nothing about whether the store behind it is reachable. The build is two stages, so Poetry and its caches never reach the runtime image. (PR #30, open)

Where the project stands today

Eleven endpoints are documented and served, eight current and three kept alive and marked deprecated so the frontend can migrate on its own schedule.

The generated OpenAPI documentation, showing the authorization, patients and providers endpoint groups.

The suite runs 306 tests offline in under two minutes, alongside a further 18 checks marked live that are excluded by default and hit real servers when they are asked for. It is built around end-to-end flows. These tests drive discovery, then the authorization URL, then the token exchange, then a read, and assert on what the caller ends up seeing.

All three shipped servers are verified live from end to end. A patient connects, logs in at the provider's own page, approves, and their records come back.

The demo landing page, headed "Connect your health records", offering a SMART sandbox and an Epic sandbox.

The provider's own consent screen, listing the three permissions the application is requesting and offering deny or approve.

The results page, showing the connected patient Abdul Koepp and 131 records retrieved across 8 clinical sections: 50 vital signs, 34 laboratory results, 16 encounters, 11 immunizations, 10 procedures, 5 diagnostic reports, 3 conditions and 2 medications.

That run used the public SMART Launcher standing in for a hospital, and the last screen is the point: 131 records across eight clinical sections, fetched with a token the browser never saw. The token stays on the server, encrypted, and all the page ever holds is a session id.

The other two servers walk the identical path. Epic sends a client secret and authenticates with HTTP Basic instead of leaning on PKCE alone, and the adapter makes that switch by itself after reading Epic's discovery document.

Oracle Health was the last to be wired in. After I got the client secret from my mentor, I set one environment variable, and the connection worked: the callback returned 200, the token went into the database encrypted, and reads came back with real data across allergies, labs, vital signs, medications, encounters and diagnostic reports. An expired access token then renewed itself on the next read with no patient involved, which is the part worth pointing at, because it means the adapter had read client_secret_basic out of Oracle's discovery document and applied it to both the first exchange and the refresh. Oracle Health is Cerner under a newer name, and the word Cerner appears nowhere in the adapter.

Here is a laboratory result as the API returns it, which is the shape a research consumer sees whichever vendor produced it:

{
  "id": "ce031c44-d8e0-443f-b35b-b1b9d31f66a6",
  "resourceType": "Observation",
  "title": "Low Density Lipoprotein Cholesterol",
  "code": { "system": "http://loinc.org", "code": "18262-6",
            "display": "Low Density Lipoprotein Cholesterol" },
  "status": "final",
  "date": "2018-11-23T23:37:54+00:00",
  "category": "laboratory",
  "detail": { "value": 84.99, "unit": "mg/dL", "interpretation": null },
  "provider": "SMART_LAUNCHER"
}

And here is the live endpoint check answering the two cases a caller has to tell apart:

{ "iss": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
  "status": "ok", "reachable": true, "smartCapable": true,
  "authorizationEndpoint": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/authorize",
  "tokenEndpoint": "https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
  "detail": "The endpoint published a SMART configuration." }

{ "iss": "https://example.com/fhir",
  "status": "no_smart_configuration", "reachable": true, "smartCapable": false,
  "authorizationEndpoint": null, "tokenEndpoint": null,
  "detail": "The endpoint is reachable but publishes no SMART configuration, so it cannot be used to sign in." }

The frontend is a separate Google Summer of Code project belonging to another contributor, so my responsibility there was to keep her unblocked and liase with her. Every change to the contract between us went out as a written note before it landed, and endpoints were deprecated rather than deleted so that nothing broke on her schedule due to my doing.

What is left to do

Two pieces are unfinished, and neither is core functionality: the backend connects, stores, refreshes and serves without either of them. They are the work that makes it safer to operate and harder to break silently.

Finishing the opt-in suite that talks to the real servers. Almost every test runs against recorded responses, which proves the application is internally consistent and proves nothing about the servers it actually talks to. That gap has already cost this project twice, when the government's endpoint feed moved and then the mirror it had moved to was pruned, and no test noticed either time.

The eighteen checks marked live close part of it. They sit out of the default run and, when asked for, they fetch each configured issuer's discovery document, assert that every captured server still publishes the fields its fixture recorded, and confirm the endpoint check can still tell three real endpoints apart. A failure there means a server moved and a capture needs retaking.

Three gaps remain. The first is the national endpoint list, which nothing checks at all. If its file moves again, or a column the parser depends on is renamed, no test would notice. That is the same kind of upstream change that has already broken this project twice, and it is still the part guarded least.

The second is normalization, which has never been run against a live response. Several public FHIR servers hand out synthetic patient records with no credentials at all, so a single read against one of those would put the normalization layer in front of something a real server produced today, rather than a response captured months ago.

The third is that there is no written procedure for retaking a capture from a live response. That is what would make a drift cheap to fold back into the fast suite once a live run catches one. Throughout, the detail that matters is that a failure has to distinguish "the server changed" from "the server is down", because those call for opposite reactions.

Observability that leaks nothing. The service handles patient data and provider access tokens, and its only observability today is the web server's access log. Two things follow: an operator cannot tell why a read degraded, and there is no standing rule keeping patient data and tokens out of whatever gets logged next. The rule I would write is that a log line may never contain an access or refresh token, a bearer, a provider-issued patient identifier, or resource content, and that it should be enforced by a test over a driven flow. Our own record identifiers are ours and are safe to log; the identifiers a provider gave us are not. There is a concrete instance waiting in the read path, where a failure is currently logged as a bare exception type because the message itself carries patient data, and the middle ground is to log the field name and the kind of failure without the value that failed. The same piece of work should also handle a stored token that cannot be decrypted, which today escapes as an uncaught 500 while the ORM is building rows. An uncaught 500 is raised above the CORS middleware and loses its headers, so the browser sees an opaque error, which is the same failure the endpoint list taught us about. It is reachable without any corruption at all: rotating the encryption key and dropping the old one does it.

What merged and what did not

The links below point at the organization's repository, which is private, so they will not open without access. Each is listed by number and title so the work is identifiable either way.

PRTitleStatus
#4Add runtime SMART discovery and provider adapter interfaceMerged
#8Persist OAuth state and encrypted tokens in PostgresMerged
#9Organize modules into an app packageMerged
#10Wire the generic adapter into the live OAuth endpointsMerged
#12Read the Lantern endpoint list from the GitHub mirrorMerged
#13Document settings, configuration, and how to run the backendMerged
#14Validate the generic adapter across servers, plus security hardeningMerged
#17Serve the newest available Lantern file and degrade instead of erroringMerged
#18Expose the configured providers for the frontend dropdownMerged
#19Fetch the US Core resource set by default and normalize responsesMerged
#27Serve the normalized record through a documented downstream APIMerged
#28Refresh expired provider tokens, and give connections a lifecycleMerged
#29Check an endpoint is usable before OAuth startsMerged
#30Bring the backend up with one commandOpen, in review

Two further issues are open by agreement and were raised with my mentor in advance ptiot tp brginning this project: #23, the opt-in live suite, and #26, the observability and cleanup pass. Both are described in the section above.

Challenges, and what I learned

A refusal is not a status code. The public SMART Launcher answers a dead refresh token with 401, where RFC 6749 prescribes 400 for an invalid_grant. Classify a refused grant on the status and you will treat a permanently dead connection as a transient failure on one server and do the reverse on another. The classification has to key on the error code in the body and never on the status, which is the sort of thing you only find by pointing your code at a real server.

Treat a data source you do not control as though it will fail, because it will. The government endpoint feed returned 404, I repointed at the mirror, and the mirror was pruned within days. The second failure taught me more than the first: the answer is not to find a better source but to serve the newest thing you already have.

A green suite only proves your code agrees with your fixtures. On the connection lifecycle work, five real defects came out of review and hands-on testing. Among them, a patient record that could hold the same provider twice and so turned a tidy-up into a 500. Recorded responses encode the assumptions you held on the day you recorded them, and a suite built only from them cannot tell you when those assumptions expire. That is why finishing the live suite is the remaining work I care most about.

Separating "our bug" from "their data" is worth doing properly. Three US Core resource types time out against Oracle Health's sandbox, taking about a minute to fail where the others answer in seconds. It would have been easy to spend a day tuning our client. Instead I ran the same searches directly against Oracle's own open endpoint, which needs no authorization and puts none of my code in the path, and they failed there too. That one move rules out authentication, scope, the patient persona and the shape of the query all at once. The cause is that the public sandbox is shared and mutable, so records have accumulated years of other developers' writes. A patient Oracle documents as having three conditions returns over two thousand. Paging does not help, because the server ignores the page size for those types and evidently assembles the whole result before returning any of it, and switching patients only moves the problem, since each one fails on a different resource. The right outcome was not a fix but a correct report: the read comes back marked degraded and lists exactly which types failed, instead of silently returning a partial chart as though it were whole.

A silent consumer is worse than a broken one. While preparing this report I pointed my own demo harness at the current service and it rendered zeros: no patient name, no records, no error. The service was fine. The harness had been written before normalization landed and was reading fields that no longer exist, and because it read them defensively it produced a confident, well formatted, entirely wrong page instead of failing.

Why only three servers? The answer is that the three were chosen to be as unalike as any SMART servers can be. Epic is a confidential client that holds a secret and authenticates with HTTP Basic. The public launcher is a public client with no secret at all, leaning entirely on PKCE. Oracle Health grants no wildcard scope, so where the others accept a request for everything, it enumerates one permission per resource type and a wildcard asks for something it never offers. The same unchanged code drives all three. The real cost of adding a fourth is not code, it is a one-time registration with that vendor to obtain credentials. I tried to obtain more but encountered different problems along the way.

The broader thing I learned is about where the difficulty in this kind of work actually sits. Very little of my summer went on algorithms. It went on reading specifications closely enough to know what a server is permitted to do, then finding out what real servers do instead, and then deciding which of those differences deserve to be written down and which deserve to be discovered.

Gratitude!

To Luca Graglia, who consistently let me own a design decision and argue it out with him afterwards, which is why the architecture here is one I can defend as mine! To Data for the Common Good, for taking on a first-time contributor and pointing him at a problem that matters. I really enjoyed working on such an impactful problem. And to the Google Summer of Code administrators, whose guidance on on what to do and submit helped me stay on track.

A copy of this report is mirrored at github.com/Phinart98/gsoc-2026-d4cg-smart-on-fhir, so the link outlives anything that happens to this site. My midterm write-up, which goes deeper on the discovery mechanism and on PKCE, is here too.