Skip to main content
Security evidence

Provider Access is not just a FHIR endpoint.

Four independent controls have to agree before any member data is assembled, and a production deployment has to trust an identity provider it does not run. Both are decisions with a wrong answer that keeps working right up until it matters.

PROV-01 · PROV-02 · PROV-03 SEC-01 · CONSENT-01 Fails closed No customer IdP onboarded

CMS-0057-F is the federal rule that requires Medicare Advantage, Medicaid, CHIP, and some exchange plans to offer FHIR APIs for patient access, provider access, payer-to-payer exchange, and prior authorization by January 1, 2027.

The starting point

Provider Access is a caller shape, not a route

There is no /provider-access endpoint. Provider Access is the ordinary /fhir/r4/{Resource} read surface reached by a provider-shaped token — one carrying user/… or system/… scopes, meaning a provider or backend service reading someone else's record.

A patient-scoped token is Patient Access: the member reading their own record, governed by the SMART patient binding rather than by Provider Access consent — a member does not need to authorize a disclosure to themselves. The distinction is drawn from the token, never from a controller name or route string, so it cannot be lost by someone adding an endpoint.

Enforcement lives in a global action filter rather than middleware, for a reason worth stating: the decision needs the tenant, and tenant resolution runs after the SMART check in the pipeline. A filter runs after the whole middleware pipeline — so authentication, scope and tenant are established facts — and still before any action body, so an unauthorized request never assembles or retrieves member data. It is registered once, globally: a new member-scoped FHIR controller is governed the moment it exists, and there is nothing to remember to opt into.

The composed decision

Four independent controls, all mandatory

A request passes downward through four independent mandatory controls: authentication against a configured trusted issuer; SMART scope enforcement with tenant binding taken from the token; provider and member attribution; and purpose-scoped ProviderAccess consent. Member data is assembled only past all four. To the side, the refusal path shows that not attributed, no consent and no such member all return one identical 403 with a byte-identical FHIR OperationOutcome, with the distinguishing category kept in the audit record.
Any one refusal denies. So does a missing tenant, a missing member context, an unidentified caller, or an unreadable consent registry — the composed decision fails closed.
Having thisDoes not imply
a correct SMART scopeattribution, or consent
attributionconsent
consentattribution
a Payer-to-Payer consentProvider Access

What the filter governs, and where the list comes from

Every member-scoped resource the FHIR surface serves: the administrative and claims types — Patient, Coverage, ExplanationOfBenefit, Encounter, Claim, Task, Communication, DocumentReference, ClaimResponse — plus the twelve USCDI clinical types. The clinical list is not restated in the filter: it is read from the same inventory the SMART layer, the routes and the CapabilityStatement read, with a test pinning them together.

Protecting Patient alone would leave the claims history readable; protecting the claims history alone would leave the clinical record readable. Taking both from one table removes the possibility rather than relying on a reviewer to notice.

Resolving the member — and refusing when there is none

A resource id alone is not resolved to a member, because resolving it means reading the resource — which is the access being authorized. No member context therefore denies rather than guesses. That is why a provider-shaped search across the whole membership is refused, and why a provider reading a clinical resource by id must also name the member.

FHIR operations are deliberately not routed through this gate. Payer-to-Payer runs its own gate for its own purpose, and an operation name is not a member id — a control applied to the wrong surface is not a stronger control.

Consent is purpose-scoped, and the separation runs both ways

Provider Access requires the ProviderAccess purpose specifically. It is the same registry and the same policy Payer-to-Payer uses; the only difference is the purpose asked for, so neither direction can drift more permissive than the other. A member with only a Payer-to-Payer consent is denied here; a member with only a Provider Access consent is denied there. A generic consent carrying no purpose authorizes neither. The required purpose is a constant in code, not configuration.

Consent is evaluated at the authorization instant chosen by the plan — never a timestamp supplied in the request — and the effective period is applied by the policy, so a record persisted as Active past its expiry still denies.

Production identity

What it takes to trust an identity provider you do not run

Cloud Health Office's FHIR service is a resource server. It does not issue production tokens and hosts no production authorization flow — the bundled authorization server is a development and acceptance-suite issuer only. Trusting a payer's real IdP is therefore a set of explicit decisions, and each of them is one that quietly goes wrong if left implicit.

Two modes, stated rather than inferred

ModeTrustsPermitted where
Demothe bundled development issuerdevelopment hosts only
ExternalIssuerthe configured trusted issuersanywhere

A Demo deployment on a non-development host fails startup. The mode is not derived from "is any external issuer configured?", because that would turn a missing config file into a silent downgrade to demo trust — the one failure nobody notices, since everything keeps working and the service simply trusts the wrong authorization server. There is no production fallback to Demo: the host does not start, and the error names the setting. The legacy single-issuer settings still work in Demo and are ignored in ExternalIssuer mode, so a half-migrated configuration cannot keep trusting the demo issuer by accident.

The issuer is resolved first, and supplies everything else

A token validates only if its iss matches a configured issuer exactly. That entry then supplies its own keys, its own audiences, its own permitted algorithms, its own claim mapping and its own permitted tenants.

The alternative — a global key set and a global audience list — quietly makes trust the union of every configured issuer's, so a token from issuer A would be accepted bearing issuer B's audience and verified against B's keys. With one IdP that is invisible. With two it is the difference between per-customer trust and one shared trust blob. Trust is administrator-controlled: nothing in a token can add an issuer, change an audience or redirect key retrieval, and keys are never fetched for an issuer that was not configured. Trust-on-first-use is not a weaker form of trust; it is the absence of it.

Rotation, and a refresh signal that is attacker-controlled

An issuer rotates on its own schedule and does not announce it. The only signal is a token whose key id is unknown — and anyone can present a token with a random key id. So refreshes are rate-limited per issuer and single-flighted: a burst carrying one new key id produces exactly one fetch and the rest wait for its result. A forged key id costs at most one fetch per interval.

Keys already retrieved stay usable while the issuer is unreachable, up to a bounded staleness age. Within that window previously-seen key ids keep working and unknown ones fail closed, so an IdP outage degrades rotation rather than signature validation. Past the bound the keys are dropped, because indefinitely stale trust would keep honouring a revoked key. Failing to retrieve keys never disables authentication — no keys, no validated tokens.

Algorithms, audience, and why HMAC is absent

RS, PS and ES families are accepted; none is absent and signed tokens are required. HMAC is absent deliberately. A symmetric verifier will accept a token signed with the issuer's public key as the shared secret — the classic algorithm-confusion attack. A resource server validating a third-party IdP only ever holds public keys, so admitting HMAC has no legitimate use and one catastrophic misuse; symmetric keys are filtered out of a key set at ingestion rather than relied on being unreachable later.

Audience is validated against the audiences of the issuer that actually signed the token, so a correctly signed token minted for another API is refused — issuer plus signature alone would have accepted it. Clock skew is configurable but capped at startup: skew large enough to meaningfully extend a token's life is lifetime validation switched off wearing a clock-drift costume.

Discovery and JWKS fetches cannot be redirected

A fetch target is refused unless configuration already named it: the issuer's own host or an explicitly listed additional host, HTTPS outside development, and never a loopback, link-local (cloud instance metadata) or private address — an allow-list entry does not override that bar. A discovery document pointing its key-set URI at an unapproved origin is refused before the request is made, because a refusal after the fetch has already performed the SSRF.

Only literal IP addresses are checked. A DNS name resolving into private space is a network-egress concern owned by the platform; re-resolving here would be a time-of-check/time-of-use check that reads as protection without being any.

Tenant binding: a header may fill a vacuum, never contradict a token

Tenant comes from the token's mapped claim first, and a service-to-service header second. A header that disagrees with the token is a 403 tenant conflict, not an override. Previously the header was consulted whenever the token carried no tenant claim, which meant any authenticated caller whose issuer did not map a tenant could name any tenant and be believed. Where an issuer declares its permitted tenants, the resolved tenant must be one of them — so customer A's IdP cannot authenticate into customer B's data however its claims are shaped. Tenant is never taken from a FHIR body or query parameter.

Provider identity is believed only when an issuer vouches for it

An NPI is public information, so a claim merely named npi proves nothing. It becomes authoritative only when a trusted issuer was configured, by an administrator, to assert it. Only the configured claim is read — never a conventionally named one — its shape is validated, and absence is treated as "no issuer has vouched for this caller's identity" rather than as "the caller has no NPI".

The default is unset. Where an issuer does assert provider identity, the CDex attachment submission compares the token's NPI against the provider the request was addressed to — closing the substitution a corroborating key cannot detect, namely a caller who knows another provider's public NPI and puts it in the payload. This only ever tightens: deployments without a provider identity claim behave exactly as before.

Configuration errors fail startup; network conditions fail readiness

Demo mode on a production host, no trusted issuer, an invalid issuer URI, a missing audience, an unsupported algorithm, a duplicate issuer or a disallowed key-set host all prevent the host from starting. An unreachable IdP does not: the service starts and readiness reports it, degraded or unhealthy according to whether any issuer has usable keys. A flood of 401s from expired tokens is a healthy resource server doing its job, and conflating that with a trust outage would make the signal fire during an attack and stay silent during an outage.

Refusals

Every refusal looks the same from outside

"Not attributed", "no consent" and "no such member" are deliberately indistinguishable: one 403 with a FHIR OperationOutcome reading "Provider Access is not authorized for this request." An acceptance test asserts the response bodies are byte-identical.

A differentiated refusal would confirm which members exist and let a caller enumerate the membership. The structured category is kept in the audit record instead, where an operator can act on it — and every decision is recorded with PHI-free identifiers only: tenant, member id, caller id, resource type, the authorizing consent id, the decision category and the evaluation instant. Grants log at information, refusals at warning. Never logged: demographics, clinical payloads, consent narrative, tokens, or credentials.

Traceability

What proves the above

Implementation evidence

Scenarios
PROV-01 attributed member data pull · PROV-02 attribution enforcement · PROV-03 opt-out honored · CONSENT-01 one consent registry · SEC-01 SMART on FHIR / OAuth · PAT-02 USCDI clinical through the same gate
Enforcement point
A global MVC action filter, running after authentication, SMART scope and tenant resolution and before any action body
Architecture docs
provider-access.md · smart-oauth-trust.md · consent.md · idp-integration-contract.md
Pull requests
#1152 purpose-scoped consent · #1153 Provider Access consent and attribution enforcement · #1157 clinical resources through the same gate · #1158 production SMART/OAuth external issuer trust
Test data
Synthetic. The acceptance suite runs against a test issuer in Demo mode — a test configuration, never a bypass.

Provider Access, security and consent scenarios from the latest published acceptance evidence.

Loading the latest published CMS-0057-F acceptance evidence. It is generated in CI from the acceptance suite at a named source revision and committed to this repository, so the status shown here is the tested status rather than a claim typed into a page.

What is not proven here

  • No customer identity provider has been onboarded. There is a real difference between the product implements production external-issuer trust and a particular payer's IdP is connected. Only the first is claimed. Connecting a specific IdP is deployment work against the documented integration contract.
  • No live attribution feed. Provider panels come from a configured catalog. It enforces for real — an empty catalog attributes nobody, and unknown provider, unknown member or blank ids all return false — but a roster integration from a payer source system is engagement work behind the same interface, and no code claims otherwise.
  • Attribution is membership-level, not date-scoped. A panel entry does not yet carry an effective period the way a consent record does.
  • Audit is emitted to the log, not to a durable audit store. The FHIR service has no audit sink.
  • Cloud Health Office does not host a production authorization flow. It is a resource server; it advertises only the configured authorization server's own endpoints, and never an authorization flow it does not host.
  • Passing every security scenario is not a security certification, a penetration test result, or an assessment by any third party.

Bring your IdP's discovery document.

The trust model is configuration an administrator controls: issuers, audiences, algorithms, claim mapping and permitted tenants. The onboarding conversation starts there.