Skip to content
Registry StackDocsv0.25.0

Issue minimum-disclosure credentials from FHIR

For the assertion provider

View as Markdown

Connect Evidence Gateway to the public SMART Health IT FHIR R4 server through a minimal local read-through adapter, then verify two minimum-disclosure answers as Selective Disclosure JSON Web Token Verifiable Credential (SD-JWT VC) responses. One answer protects a patient: it discloses a coverage result instead of the FHIR resource behind it. The other applies the same pattern to a healthcare establishment, where the subject is an organization rather than a person.

Outcome
Two verified SD-JWT VC responses derived from live FHIR R4 Coverage and Organization resources.
Time
About 25 minutes
Level
Local development against a public synthetic-data server
Prerequisites
The Evidence Gateway toolsetA shell with curlPython 3An editorInternet accessLinux or macOS

Complete Get your first Evidence Gateway assertion if you have not used evidencectl before.

This tutorial was last run end to end against the SMART Health IT R4 open endpoint at https://r4.smarthealthit.org on 10 August 2026. It is a public demonstration server containing synthetic data. Its operator can add, change, or reset records, so the discovery result and expected true answers can change. Do not send real patient identifiers or other sensitive data to it.

The steps below work with this server as written. Both scripts carry a test-only origin override that accepts only a numeric-loopback HTTP endpoint, so Registry Docs can replay the same steps against sanitized local records. Another FHIR server may use different resource profiles, data, authorization, search behavior, or JSON shapes. Do not swap the server URL and assume the same evidence claim still holds.

The two questions return narrow administrative facts:

SubjectFHIR resourceGoverned answer
Patient and coverage recordCoverageThe selected record reports active and names the selected patient as beneficiary.
Healthcare establishmentOrganizationThe selected record reports active and carries the standard healthcare-provider organization type.

An active Coverage record is not an independent insurance-eligibility decision. An active healthcare-provider Organization is not facility accreditation or a professional licence. A real deployment can rely on either answer only when its FHIR server is authoritative for that fact and the governing rule has been reviewed.

Create a working directory:

Terminal window
mkdir fhir-evidence-credentials
cd fhir-evidence-credentials

Create discover-fhir-records.py. The script searches the demo server for one active Coverage record whose beneficiary resolves to a Patient that exists, and one active Organization typed as a healthcare provider. It writes only their selectors to two owner-only local files, and prints neither identifiers nor FHIR resources:

import json
import os
import re
from urllib.parse import urlencode, urlsplit
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
DEFAULT_BASE_URL = "https://r4.smarthealthit.org"
BASE_URL = os.environ.get("FHIR_TUTORIAL_TEST_BASE_URL", DEFAULT_BASE_URL)
if BASE_URL != DEFAULT_BASE_URL:
parsed_origin = urlsplit(BASE_URL)
if (
parsed_origin.scheme != "http"
or parsed_origin.hostname != "127.0.0.1"
or parsed_origin.port is None
or parsed_origin.username is not None
or parsed_origin.password is not None
or parsed_origin.path not in {"", "/"}
or parsed_origin.query
or parsed_origin.fragment
):
raise RuntimeError("the tutorial test origin must be numeric loopback HTTP")
BASE_URL = BASE_URL.rstrip("/")
MAXIMUM_BYTES = 1_048_576
FHIR_ID = re.compile(r"^[A-Za-z0-9.-]{1,64}$")
ORGANIZATION_TYPE_SYSTEM = (
"http://terminology.hl7.org/CodeSystem/organization-type"
)
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
return None
OPENER = build_opener(ProxyHandler({}), NoRedirect)
def read_fhir(path, parameters=None, missing_ok=False):
query = f"?{urlencode(parameters)}" if parameters else ""
request = Request(
f"{BASE_URL}/{path}{query}",
headers={"Accept": "application/fhir+json"},
)
try:
with OPENER.open(request, timeout=20) as response:
if response.status != 200:
raise RuntimeError("FHIR server returned a non-success status")
if response.headers.get_content_type() not in {
"application/fhir+json",
"application/json",
}:
raise RuntimeError("FHIR server returned an unexpected media type")
body = response.read(MAXIMUM_BYTES + 1)
except HTTPError as error:
if missing_ok and error.code in {404, 410}:
return None
raise RuntimeError("FHIR server returned a non-success status") from None
if len(body) > MAXIMUM_BYTES:
raise RuntimeError("FHIR response exceeded the discovery bound")
return json.loads(body)
def resources(bundle, resource_type):
if bundle.get("resourceType") != "Bundle":
raise RuntimeError("FHIR search did not return a Bundle")
for entry in bundle.get("entry", []):
resource = entry.get("resource", {})
if resource.get("resourceType") == resource_type:
yield resource
coverage_selection = None
coverage_bundle = read_fhir(
"Coverage",
{"status": "active", "_count": "100"},
)
for coverage in resources(coverage_bundle, "Coverage"):
coverage_id = coverage.get("id")
reference = coverage.get("beneficiary", {}).get("reference", "")
if (
coverage.get("status") != "active"
or not isinstance(coverage_id, str)
or not FHIR_ID.fullmatch(coverage_id)
or not reference.startswith("Patient/")
):
continue
patient_id = reference.removeprefix("Patient/")
if not FHIR_ID.fullmatch(patient_id):
continue
patient = read_fhir(f"Patient/{patient_id}", missing_ok=True)
if (
patient is not None
and patient.get("resourceType") == "Patient"
and patient.get("id") == patient_id
):
coverage_selection = {
"subjects": [
{"role": "patient", "field": "patient_id", "value": patient_id},
{
"role": "coverage-record",
"field": "coverage_id",
"value": coverage_id,
},
]
}
break
organization_selection = None
organization_bundle = read_fhir(
"Organization",
{
"active": "true",
"type": f"{ORGANIZATION_TYPE_SYSTEM}|prov",
"_count": "100",
},
)
for organization in resources(organization_bundle, "Organization"):
organization_id = organization.get("id")
organization_types = organization.get("type", [])
coding_groups = [
organization_type.get("coding", [])
for organization_type in organization_types
]
codings = [
coding
for coding_group in coding_groups
for coding in coding_group
]
if (
organization.get("active") is True
and isinstance(organization_id, str)
and FHIR_ID.fullmatch(organization_id)
and 1 <= len(organization_types) <= 4
and all(1 <= len(coding_group) <= 8 for coding_group in coding_groups)
and len(codings) == 1
and codings[0].get("system") == ORGANIZATION_TYPE_SYSTEM
and codings[0].get("code") == "prov"
):
organization_selection = {
"subjects": [
{
"role": "organization",
"field": "organization_id",
"value": organization_id,
}
]
}
break
if coverage_selection is None or organization_selection is None:
raise RuntimeError("no coherent tutorial records are currently available")
for path, selection in [
("fhir-coverage-subjects.json", coverage_selection),
("fhir-organization-subjects.json", organization_selection),
]:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as destination:
json.dump(selection, destination, separators=(",", ":"))
destination.write("\n")
print("Coverage selector file: ready")
print("Organization selector file: ready")
Terminal window
python3 discover-fhir-records.py
Coverage selector file: ready
Organization selector file: ready

Two ready lines are the whole output: no patient identifier, no coverage identifier, no FHIR resource. Those values stay in the selector files, which sit outside the project you create next, and Evidence Gateway reads them from there rather than from a shell argument or command output.

Evidence Gateway’s frozen Version 1 bundle grammar permits credential-free source access only on numeric loopback under local assurance. The adapter below holds that boundary while forwarding each governed read to the real SMART FHIR server. It carries no fixtures or seeded responses, binds only to loopback, accepts only the two FHIR resource paths used below, denies redirects, validates the returned resource identity, bounds the response, and suppresses request logging so selectors never reach a log file.

Create fhir-read-through.py:

import json
import os
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
DEFAULT_BASE_URL = "https://r4.smarthealthit.org"
BASE_URL = os.environ.get("FHIR_TUTORIAL_TEST_BASE_URL", DEFAULT_BASE_URL)
if BASE_URL != DEFAULT_BASE_URL:
parsed_origin = urlsplit(BASE_URL)
if (
parsed_origin.scheme != "http"
or parsed_origin.hostname != "127.0.0.1"
or parsed_origin.port is None
or parsed_origin.username is not None
or parsed_origin.password is not None
or parsed_origin.path not in {"", "/"}
or parsed_origin.query
or parsed_origin.fragment
):
raise RuntimeError("the tutorial test origin must be numeric loopback HTTP")
BASE_URL = BASE_URL.rstrip("/")
MAXIMUM_BYTES = 1_048_576
RESOURCE_PATH = re.compile(r"^/(Coverage|Organization)/([A-Za-z0-9.-]{1,64})$")
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
return None
OPENER = build_opener(ProxyHandler({}), NoRedirect)
class FhirReadThrough(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format, *args):
return
def send_body(self, status, body, content_type):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def fail(self, status):
self.send_body(status, b'{"error":"FHIR read failed"}\n', "application/json")
def do_GET(self):
if self.path == "/healthz":
self.send_body(200, b"ready\n", "text/plain")
return
match = RESOURCE_PATH.fullmatch(self.path)
if match is None:
self.fail(404)
return
resource_type, resource_id = match.groups()
request = Request(
f"{BASE_URL}/{resource_type}/{resource_id}",
headers={"Accept": "application/fhir+json"},
)
try:
with OPENER.open(request, timeout=20) as response:
if response.status != 200 or response.headers.get_content_type() not in {
"application/fhir+json",
"application/json",
}:
self.fail(502)
return
body = response.read(MAXIMUM_BYTES + 1)
if len(body) > MAXIMUM_BYTES:
self.fail(502)
return
resource = json.loads(body)
if (
resource.get("resourceType") != resource_type
or resource.get("id") != resource_id
):
self.fail(502)
return
except HTTPError as error:
self.fail(404 if error.code in {404, 410} else 502)
return
except (URLError, TimeoutError, ValueError):
self.fail(502)
return
self.send_body(200, body, "application/json")
server = ThreadingHTTPServer(("127.0.0.1", 8000), FhirReadThrough)
server.daemon_threads = True
print("FHIR read-through adapter ready on 127.0.0.1:8000", flush=True)
server.serve_forever()

Start it and confirm that it is ready:

Terminal window
umask 077
python3 fhir-read-through.py >fhir-read-through.log 2>&1 &
printf '%s\n' "$!" >fhir-read-through.pid
for attempt in 1 2 3 4 5; do
if curl --silent --fail http://127.0.0.1:8000/healthz >/dev/null; then
break
fi
sleep 1
done
curl --silent --show-error --fail http://127.0.0.1:8000/healthz
ready

This is a transport adapter, not a mock FHIR server. Every Coverage or Organization response Evidence Gateway uses is fetched live from https://r4.smarthealthit.org at request time. The adapter asks the upstream server for FHIR JSON, validates the resource, and exposes the same JSON body as application/json, which is inside Evidence Gateway’s frozen v1 source contract. Both scripts ignore ambient proxy variables, so a selector cannot be sent through an undeclared intermediary.

A FHIR server publishes a CapabilityStatement, which lists what the server supports but is not an OpenAPI description. Evidence Gateway compiles a narrow OpenAPI contract instead, so the origin, paths, response media type, projected fields, and bounds are all stated explicitly.

Save this reviewed subset as fhir-smart-r4.openapi.yaml:

openapi: 3.1.0
info:
title: SMART Health IT public FHIR R4 tutorial subset
version: 1.0.0
servers:
- url: http://127.0.0.1:8000
paths:
/Coverage/{coverage_id}:
get:
operationId: getCoverage
parameters:
- name: coverage_id
in: path
required: true
schema: {type: string}
responses:
'200':
description: One FHIR R4 Coverage resource
content:
application/json:
schema:
type: object
required: [resourceType, id, status, beneficiary]
properties:
resourceType: {type: string, const: Coverage}
id: {type: string, minLength: 1, maxLength: 64}
status:
type: string
enum: [active, cancelled, draft, entered-in-error]
beneficiary:
type: object
required: [reference]
properties:
reference: {type: string, minLength: 1, maxLength: 128}
/Organization/{organization_id}:
get:
operationId: getOrganization
parameters:
- name: organization_id
in: path
required: true
schema: {type: string}
responses:
'200':
description: One FHIR R4 Organization resource
content:
application/json:
schema:
type: object
required: [resourceType, id, active, type]
properties:
resourceType: {type: string, const: Organization}
id: {type: string, minLength: 1, maxLength: 64}
active: {type: boolean}
type:
type: array
minItems: 1
maxItems: 4
items:
type: object
required: [coding]
properties:
coding:
type: array
minItems: 1
maxItems: 8
items:
type: object
required: [system, code]
properties:
system:
type: string
const: http://terminology.hl7.org/CodeSystem/organization-type
code: {type: string, minLength: 1, maxLength: 64}

Create an editable local project from that contract:

Terminal window
evidencectl new fhir-record-evidence \
--openapi fhir-smart-r4.openapi.yaml \
--profile local
cd fhir-record-evidence
Created an editable OpenAPI authoring project in fhir-record-evidence

The local compiler recognizes the numeric-loopback server as a credential-free none source. The adapter owns the system-TLS connection to the public FHIR server. This arrangement is for local authoring only; a deployment must use a reviewed authenticated HTTPS source.

Create questions/fhir-coverage-status.yaml:

id: fhir-coverage-status
question: Does this coverage record report active coverage for the selected patient?
purpose: coverage-record-verification
subjects:
- role: patient
selector: patient_id
derivation: true
- role: coverage-record
selector: coverage_id
source: true
derivation: true
source:
operation: getCoverage
facts:
- {name: resource_id, path: /id, combine: exactly-one}
- {name: status, path: /status, combine: exactly-one}
- {name: beneficiary_reference, path: /beneficiary/reference, combine: exactly-one}
collectionBounds: {}
answers:
- concept: coverage_record_reports_active
type: boolean
responseFormats: [signed-jws, sd-jwt-vc]
derivation: derivations/fhir-coverage-status.rhai
disclosure:
allow: [coverage_record_reports_active]

Only the coverage-record role, marked source: true, fills the FHIR path. The patient identifier is derivation-only, so it does not widen the source request or cause Evidence Gateway to fetch a Patient resource.

Create derivations/fhir-coverage-status.rhai:

fn answer(facts, selectors, context) {
let coverage_id = selectors["coverage-record"]["values"]["coverage_id"];
let patient_id = selectors["patient"]["values"]["patient_id"];
if required(facts.resource_id, "coverage_id_missing") != coverage_id {
throw("derivation_input_error");
}
if required(facts.beneficiary_reference, "beneficiary_missing") !=
"Patient/" + patient_id {
throw("derivation_input_error");
}
#{
coverage_record_reports_active:
required(facts.status, "coverage_status_missing") == "active"
}
}

A beneficiary mismatch fails closed. It never becomes a signed false answer about a different patient. The source projection retains only id, status, and beneficiary.reference; it drops the payor, subscriber identifier, coverage class, period, and every other source field.

Author the healthcare-establishment question

Section titled “Author the healthcare-establishment question”

Create questions/fhir-healthcare-establishment.yaml:

id: fhir-healthcare-establishment
question: Does this organization record report an active healthcare provider?
purpose: healthcare-establishment-verification
subject:
role: organization
selector: organization_id
derivation: true
source:
operation: getOrganization
facts:
- {name: resource_id, path: /id, combine: exactly-one}
- {name: active, path: /active, combine: exactly-one}
- {name: type_systems, path: /type/*/coding/*/system, combine: collect}
- {name: type_codes, path: /type/*/coding/*/code, combine: collect}
collectionBounds:
/type: 4
/type/*/coding: 8
answers:
- concept: healthcare_provider_record_active
type: boolean
responseFormats: [signed-jws, sd-jwt-vc]
derivation: derivations/fhir-healthcare-establishment.rhai
disclosure:
allow: [healthcare_provider_record_active]

Create derivations/fhir-healthcare-establishment.rhai:

fn answer(facts, selectors, context) {
let organization_id = selectors["organization"]["values"]["organization_id"];
if required(facts.resource_id, "organization_id_missing") != organization_id {
throw("derivation_input_error");
}
let type_systems = required(facts.type_systems, "organization_type_system_missing");
let type_codes = required(facts.type_codes, "organization_type_code_missing");
#{
healthcare_provider_record_active:
required(facts.active, "organization_active_missing") &&
type_systems.len == 1 &&
type_systems[0] ==
"http://terminology.hl7.org/CodeSystem/organization-type" &&
type_codes.len == 1 &&
type_codes[0] == "prov"
}
}

The result reports only what this FHIR record says. It does not claim that a regulator has licensed the organization, that a particular practitioner works there, or that the facility is accredited.

Compile the two questions and start Evidence Gateway and Registry Mint:

Terminal window
evidencectl dev --detach
Evidence ready at http://127.0.0.1:8080
Mint ready at http://127.0.0.1:8081

Evidence Gateway now reads the live SMART server through the loopback adapter. It fixes one GET path per question, asks the adapter for application/json, applies response-size and timeout bounds, and projects the response before the derivation runs. The adapter independently asks the upstream server for application/fhir+json, denies redirects, and validates the remote FHIR resource identity before returning it.

Prepare a request bound to the patient and coverage-record roles:

Terminal window
evidencectl request prepare fhir-coverage-status \
--purpose coverage-record-verification \
--subjects-file ../fhir-coverage-subjects.json \
--format sd-jwt-vc \
--name fhir-coverage-vc
Prepared request: .evidence/requests/fhir-coverage-vc/request.json
Prepared verification context: .evidence/requests/fhir-coverage-vc/verification.json
Prepared authorization: .evidence/requests/fhir-coverage-vc/authorization.curl

Preparation writes three files: the request body, the verification context evidencectl verify checks the response against, and short-lived authorization for the call. Nothing has been read from the FHIR server yet; that happens when you send the request.

Request and verify the credential:

Terminal window
curl --silent --show-error --fail-with-body \
--config .evidence/requests/fhir-coverage-vc/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/dc+sd-jwt' \
--data-binary @.evidence/requests/fhir-coverage-vc/request.json \
--output fhir-coverage.sd-jwt \
--write-out 'HTTP %{http_code}\n'
evidencectl verify fhir-coverage.sd-jwt \
--context .evidence/requests/fhir-coverage-vc/verification.json \
--output fhir-coverage.verified.json
HTTP 200
VERIFIED

Inspect the verified payload, not the unverified credential:

Terminal window
python3 -m json.tool fhir-coverage.verified.json

The verified payload carries one supported value:

{
"providesValueFor": "urn:registrystack:evidence:local:concept:fhir-coverage-status:coverage_record_reports_active",
"value": true
}

Alongside it the payload carries pseudonymous bindings for patient and coverage-record. Read it for what is missing: neither FHIR identifier is there, nor the beneficiary reference, nor any of the Coverage fields the question did not select.

Request the healthcare-establishment credential

Section titled “Request the healthcare-establishment credential”

Prepare, request, and verify the second credential:

Terminal window
evidencectl request prepare fhir-healthcare-establishment \
--purpose healthcare-establishment-verification \
--subjects-file ../fhir-organization-subjects.json \
--format sd-jwt-vc \
--name fhir-healthcare-establishment-vc
curl --silent --show-error --fail-with-body \
--config .evidence/requests/fhir-healthcare-establishment-vc/authorization.curl \
--request POST \
--url http://127.0.0.1:8080/v1/evidence \
--header 'Content-Type: application/json' \
--header 'Accept: application/dc+sd-jwt' \
--data-binary @.evidence/requests/fhir-healthcare-establishment-vc/request.json \
--output fhir-healthcare-establishment.sd-jwt \
--write-out 'HTTP %{http_code}\n'
evidencectl verify fhir-healthcare-establishment.sd-jwt \
--context .evidence/requests/fhir-healthcare-establishment-vc/verification.json \
--output fhir-healthcare-establishment.verified.json
Prepared request: .evidence/requests/fhir-healthcare-establishment-vc/request.json
HTTP 200
VERIFIED

The verified payload carries healthcare_provider_record_active: true. It does not repeat the organization name, source identifier, contact details, address, or FHIR type code. A relying party learns that the record describes an active healthcare provider, and not which record it is.

Stop the local services, inspect the last completed operation, and remove generated local state:

Terminal window
evidencectl dev stop
evidencectl audit show --last-operation
evidencectl dev clean
cd ..
kill "$(cat fhir-read-through.pid)" 2>/dev/null || true
rm -f \
discover-fhir-records.py \
fhir-read-through.py \
fhir-read-through.log \
fhir-read-through.pid \
fhir-coverage-subjects.json \
fhir-organization-subjects.json

The audit names the authorized question, the purpose it was authorized under, the requester pseudonym, and the concept that was released. Read it for what it leaves out: no selected FHIR identifier, no source resource, and not even the boolean value itself. An operator can establish that a caller was authorized to receive this answer under a stated purpose, and cannot read the answer out of the audit. The last commands remove the discovery script and the selector files.

  • Evidence Gateway read two live resources from one public FHIR R4 server through a local read-through adapter, not a mock data service.
  • The patient question checked the Coverage-to-Patient relationship without fetching or disclosing the Patient resource.
  • The establishment question applied the same bounded pattern outside patient data.
  • Two verified SD-JWT VC responses carried boolean answers and pseudonymous subject bindings instead of FHIR resources or identifiers.
  • The credential-free source stayed loopback-only, and it is not a route to production source authentication.
SymptomCauseResolution
Record discovery reports that no coherent records are availableThe public demo data changed, was reset, or no current resource matches the tutorial’s narrow conditions.Remove any selector files already created, then rerun the discovery step later. Do not weaken the relationship or organization-type checks.
The read-through adapter does not startPort 8000 is already in use, Python cannot bind loopback, or the process exited.Stop the process using port 8000, inspect the owner-only fhir-read-through.log, and start the adapter again. Do not change the OpenAPI origin without reviewing the local-source boundary.
Evidence Gateway reports a source dependency failureThe record changed shape, no longer matches the narrow schema, timed out, redirected, or returned a non-success status.Probe the exact read again. Update the OpenAPI subset only after comparing it with the server’s current FHIR profile and reviewing the claim semantics.
The verified answer is falseThe live record no longer reports the governed status or type.Treat false as the server’s current administrative answer. Do not edit the derivation merely to recover the tutorial’s former output.
A production FHIR server refuses the requestThis tutorial uses an explicitly local, anonymous demo posture and a loopback FHIR-to-JSON adapter.Deploy a reviewed authenticated HTTPS adapter that applies the institution’s FHIR trust and authentication, commonly SMART Backend Services, and exposes the bounded response as application/json; then build under production or evidence-grade assurance.
  • Connect an institution source from OpenAPI when the FHIR server belongs to an institution and requires reviewed schemas and credentials.
  • Configure Evidence Gateway for SMART on FHIR Backend Services private-key authentication and production source trust.
  • Model a practitioner licence only after identifying an authoritative FHIR profile, code system, issuer relationship, and revocation semantics. A generic Practitioner.qualification entry is not automatically a licence.
  • Verify an assertion as a consumer when another application will make the trust decision.