Handling Media Files in FHIR
Use case
A telehealth platform lets patients upload photos of skin conditions so a dermatologist can review them remotely. The photos are identifiable medical information - they are tied to a specific patient, may reveal the patient's body, and constitute part of the medical record. The platform must ensure that:
- Images are stored efficiently without bloating the FHIR database.
- Only authorized users can access images - patients see only their own, practitioners see only images for patients in their organization.
- Even if a URL is intercepted or leaked, it cannot be used indefinitely to access the image.
Other scenarios this recipe applies to
- Radiology report PDFs: A radiology department attaches X-ray images and MRI summaries to DiagnosticReport resources. Unauthorized access to these files could violate HIPAA or GDPR.
- Patient portal documents: A hospital patient portal lets patients upload insurance cards or ID documents as supporting documentation in DocumentReference resources.
What you will build
Prerequisites
- Fire Arrow Server is running and accessible. See Getting Started.
- Azure Blob Storage account is provisioned (connection string or managed identity configured).
- Authentication is enabled and clients can obtain bearer tokens. See Authentication.
Choosing the right FHIR resource
FHIR resources are designed for structured clinical data - names, codes, dates, references. They are not designed to carry large binary payloads. Embedding a 5 MB image as a base64-encoded string inside a resource like Patient.photo would mean that every time anyone reads that Patient, they download megabytes of image data. Searches slow down, the database grows rapidly, and network bandwidth is wasted.
Fire Arrow Server solves this with external binary storage: files are uploaded separately to Azure Blob Storage, and a lightweight firearrow:// URL is stored in the FHIR resource. The actual file never enters the FHIR database.
The next question is: which FHIR resource should hold the reference to the file? The answer depends on what the file represents.
| Your use case | Recommended resource | Why |
|---|---|---|
| Clinical documents (lab reports, discharge summaries, clinical photos for review) | DocumentReference | Carries rich metadata: author, date, category, security labels, and supports multiple attachments in different formats. |
| Diagnostic media captures (ultrasound images, dermatoscopy photos, audio recordings of heart sounds) | Media | Carries imaging-specific metadata: modality, device, bodySite, view. Designed for media produced during clinical encounters. |
| Formatted output of a diagnostic workflow (radiology report PDF, pathology slide) | DiagnosticReport (via presentedForm) | The report resource already describes the diagnostic context; presentedForm holds the human-readable output. |
| Patient identification photo (avatar) | Patient (via photo) | Only for identification photos shown in patient lists, not for clinical images. |
| Insurance cards, consent forms, administrative documents | DocumentReference | Same as clinical documents, but with an administrative category coding. |
For the telehealth skin condition scenario, DocumentReference is the best fit. It lets you record who took the photo, when, what body site it relates to, and what clinical category it falls under.
Securing access to medical images
Medical images are among the most sensitive data in healthcare. A photo of a skin condition is tied to a specific patient, may be identifiable on its own, and is subject to privacy regulation (HIPAA, GDPR, and national health data laws). Protecting these images requires multiple layers of security, each addressing a different threat.
Layer 1: Authorization rules control who can read the FHIR resource
Fire Arrow Server's authorization system evaluates every request against a set of rules. When a client requests a DocumentReference, the server checks whether the client's role, the resource type, and the operation match a configured rule, and whether the rule's validator grants access.
For the telehealth scenario:
- A patient should only see DocumentReferences that belong to them. The PatientCompartment validator ensures this - it checks that the resource's subject is the authenticated patient.
- A practitioner (the dermatologist) should see DocumentReferences for all patients in their organization. The LegitimateInterest validator handles this - it resolves the practitioner's organizational memberships and grants access to resources belonging to patients in those organizations.
If no rule matches, the Forbidden default validator denies the request. This means a practitioner at Clinic B cannot see a photo uploaded by a patient at Clinic A, even if they somehow know the resource ID.
Layer 2: Binary upload requires its own authorization
The $binary-upload operation is not just another write. It is a separate operation type (binary-upload) that requires its own authorization rule. The server also validates that the caller has write access to the resource referenced by resourceReference. This prevents a scenario where a user can upload files but attach them to resources they cannot modify.
Layer 3: Pre-signed URLs protect the actual file
Even with authorization on the FHIR resource, the actual image file sits in Azure Blob Storage. Without additional protection, you would face two bad options:
- Permanent public URL: Anyone who obtains the URL can access the image forever, even without authenticating to Fire Arrow Server. A leaked URL means a permanent data breach.
- Server-proxied downloads: Fire Arrow Server downloads the file from Azure and streams it to the client. This is secure but inefficient - the server becomes a bottleneck for every image download, and you lose the performance benefits of a CDN or direct blob access.
Pre-signed URLs solve both problems. When Fire Arrow Server returns a resource containing a firearrow:// URL, it replaces it on-the-fly with a time-limited Azure Blob Storage URL that includes a cryptographic signature and an expiration timestamp. The client downloads the image directly from Azure (efficient), but the URL stops working after the timeout (secure). Even if the URL is intercepted, it becomes useless after expiration.
Choosing the right expiration timeout
The presigned-url.expiration-seconds setting controls how long a pre-signed URL remains valid. The right value depends on your use case:
| Scenario | Recommended timeout | Reasoning |
|---|---|---|
| Telehealth photo review | 120 seconds (default) | The dermatologist's app fetches the image when opening the case. Two minutes is enough to load even on a slow connection, but short enough that a leaked URL expires quickly. |
| Radiology viewing station | 300 seconds | Radiologists may open multiple images in a PACS viewer and switch between them. A longer window avoids interruptions, but the station is typically on a secured hospital network. |
| Patient portal document download | 120 seconds | Patients click a download link; the file should start immediately. Two minutes provides a comfortable margin. |
| Batch processing / automated pipelines | 60 seconds | Automated systems fetch images immediately. A short timeout minimizes the risk window. |
Pre-signed URLs are generated fresh on every read. Your client should fetch the resource just before it needs the image, not cache the URL for later. If the URL expires, simply read the resource again to get a new one.
Complete authorization rules
Here is a full application.yaml authorization block for the telehealth scenario. Every rule is explained.
fire-arrow:
authorization:
default-validator: Forbidden
validation-rules:
# --- Patient rules ---
# Patients can upload images linked to their own resources
- client-role: Patient
resource: Binary
operation: binary-upload
validator: PatientCompartment
# Patients can read and search their own DocumentReferences
- client-role: Patient
resource: DocumentReference
operation: read
validator: PatientCompartment
- client-role: Patient
resource: DocumentReference
operation: search
validator: PatientCompartment
# GraphQL equivalents (if your app uses GraphQL)
- client-role: Patient
resource: DocumentReference
operation: graphql-read
validator: PatientCompartment
- client-role: Patient
resource: DocumentReference
operation: graphql-search
validator: PatientCompartment
# Patients can read their own Patient resource (needed for Patient.photo)
- client-role: Patient
resource: Patient
operation: read
validator: PatientCompartment
# Patients can verify their identity
- client-role: Patient
resource: Patient
operation: me
validator: PatientCompartment
# --- Practitioner rules ---
# Practitioners can upload images linked to resources in their organization
- client-role: Practitioner
resource: Binary
operation: binary-upload
validator: LegitimateInterest
# Practitioners can manage DocumentReferences for patients in their org
- client-role: Practitioner
resource: DocumentReference
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: DocumentReference
operation: search
validator: LegitimateInterest
- client-role: Practitioner
resource: DocumentReference
operation: create
validator: LegitimateInterest
- client-role: Practitioner
resource: DocumentReference
operation: update
validator: LegitimateInterest
# GraphQL equivalents
- client-role: Practitioner
resource: DocumentReference
operation: graphql-read
validator: LegitimateInterest
- client-role: Practitioner
resource: DocumentReference
operation: graphql-search
validator: LegitimateInterest
# Practitioners can read patient records (needed for context and Patient.photo)
- client-role: Practitioner
resource: Patient
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: Patient
operation: search
validator: LegitimateInterest
A rule for read does not automatically grant graphql-read. If your clients use both REST and GraphQL, you need rules for each. See Authorization Concepts for details.
Step-by-step instructions
Step 1: Enable binary storage
Add the binary storage configuration to your application.yaml:
fire-arrow:
binary-storage:
enabled: true
azure:
connection-string: "${AZURE_STORAGE_CONNECTION_STRING}"
container-name: "fhir-attachments"
auto-create-container: true
presigned-url:
expiration-seconds: 120
max-file-size: 10485760 # 10 MB
allowed-content-types: [] # Empty = allow all types
| Setting | What it does |
|---|---|
enabled | Activates the binary storage feature and registers the $binary-upload operation. |
azure.connection-string | Connection string for your Azure Storage account. Use an environment variable to keep secrets out of config files. |
azure.container-name | The blob container where files are stored. Each file is stored at {ResourceType}/{ResourceId}/{uuid}.{ext}. |
auto-create-container | Creates the container on startup if it doesn't exist. Useful for development; in production, pre-create the container with appropriate access policies. |
presigned-url.expiration-seconds | How long generated URLs remain valid. See the timeout recommendations above. |
max-file-size | Maximum upload size in bytes. Requests exceeding this are rejected. |
allowed-content-types | Optional allowlist of MIME types. Leave empty to accept any file type, or restrict to image types like ["image/jpeg", "image/png", "application/pdf"]. |
For production deployments on Azure, consider using managed identity instead of a connection string:
fire-arrow:
binary-storage:
enabled: true
azure:
service-url: "https://myaccount.blob.core.windows.net"
credential-mode: managed-identity
container-name: "fhir-attachments"
For the full reference, see Binary Storage.
Step 2: Configure authorization rules
Copy the authorization rules from the Complete authorization rules section above into your application.yaml. Restart the server for the changes to take effect.
To verify that the rules are loaded correctly, you can use the authorization debug mode:
# Enable debug mode in application.yaml:
# fire-arrow.authorization.debug-enabled: true
# Then test with the debug header:
curl -H "Authorization: Bearer <patient-token>" \
-H "X-Fire-Arrow-Debug: true" \
http://localhost:8080/fhir/DocumentReference/123
If the request is denied, the response will show exactly which rules were evaluated and why each one matched or didn't. This is invaluable for troubleshooting.
Step 3: Upload a clinical image via the API
Use the $binary-upload operation to upload the image file. The operation accepts multipart form data:
curl -X POST http://localhost:8080/fhir/\$binary-upload \
-H "Authorization: Bearer <your-token>" \
-F "resourceReference=Patient/patient-123" \
-F "[email protected]"
The resourceReference parameter tells the server which resource this file belongs to. The server checks that the caller has write access to that resource before accepting the upload.
The response is a FHIR Parameters resource containing the storage URL:
{
"resourceType": "Parameters",
"parameter": [
{ "name": "url", "valueUrl": "firearrow://fhir-attachments/Patient/patient-123/a1b2c3d4-skin-photo.jpg" },
{ "name": "contentType", "valueString": "image/jpeg" },
{ "name": "size", "valueInteger": 2048576 }
]
}
Save the firearrow:// URL - you will use it in the next step.
For programmatic uploads (e.g., from a backend service), you can also use the JSON/base64 format:
curl -X POST http://localhost:8080/fhir/\$binary-upload \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceReference": "Patient/patient-123",
"data": "<base64-encoded-image>",
"contentType": "image/jpeg",
"filename": "skin-condition-photo.jpg"
}'
Step 4: Upload via the Web UI
If you prefer the Web UI, open the Resource Browser and navigate to the resource you want to attach a file to (e.g., a DocumentReference or Patient). When editing a resource that has attachment fields, the UI provides a built-in file uploader that handles $binary-upload automatically.
For DocumentReference resources:
- Open Resources in the sidebar and select DocumentReference.
- Create a new resource or edit an existing one.
- In the
contentsection, the attachment field offers an Upload button. - Select your file - the UI uploads it via
$binary-uploadand populates thefirearrow://URL automatically.
Step 5: Create a DocumentReference with the image
Now create a DocumentReference that holds the metadata about the image and the firearrow:// URL:
curl -X POST http://localhost:8080/fhir/DocumentReference \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "DocumentReference",
"status": "current",
"type": {
"coding": [{
"system": "http://loinc.org",
"code": "72170-4",
"display": "Photographic image"
}]
},
"category": [{
"coding": [{
"system": "http://loinc.org",
"code": "LP29684-5",
"display": "Radiology"
}]
}],
"subject": {
"reference": "Patient/patient-123"
},
"date": "2026-04-04T10:30:00Z",
"author": [{
"reference": "Patient/patient-123"
}],
"description": "Photo of skin condition on left forearm, uploaded for dermatology consultation",
"content": [{
"attachment": {
"contentType": "image/jpeg",
"url": "firearrow://fhir-attachments/Patient/patient-123/a1b2c3d4-skin-photo.jpg",
"title": "Skin condition - left forearm"
}
}]
}'
Key fields:
subjectlinks the document to the patient. This is what the PatientCompartment and LegitimateInterest validators use to determine access.typeandcategoryclassify the document for searching and browsing.content[].attachment.urlholds thefirearrow://URL from the upload step.
Step 6: Read the resource and access the image
When any authorized client reads this DocumentReference, Fire Arrow Server automatically replaces the firearrow:// URL with a time-limited pre-signed Azure Blob Storage URL:
curl http://localhost:8080/fhir/DocumentReference/doc-456 \
-H "Authorization: Bearer <your-token>"
Response (note the resolved URL):
{
"resourceType": "DocumentReference",
"id": "doc-456",
"content": [{
"attachment": {
"contentType": "image/jpeg",
"url": "https://myaccount.blob.core.windows.net/fhir-attachments/Patient/patient-123/a1b2c3d4-skin-photo.jpg?sv=2021-06-08&se=2026-04-04T10%3A32%3A00Z&sig=...",
"title": "Skin condition - left forearm"
}
}]
}
The url is now a pre-signed Azure URL with se= (expiry) and sig= (signature) parameters. Your client should download the image immediately using this URL. After the configured timeout (120 seconds by default), the URL expires and returns a 403 error from Azure.
To get a fresh URL, simply read the DocumentReference again.
Step 7: Automatic cleanup
When you delete a DocumentReference (or update it to remove an attachment URL), Fire Arrow Server automatically deletes the corresponding blob from Azure Blob Storage. You do not need to manage blob lifecycle separately.
This also applies when a resource is updated to replace one attachment with another - the old blob is deleted after the update transaction commits.
Step 8: Verify security end-to-end
Test that the security layers work as expected:
Verify authorization denies unauthorized access:
# Use a token for a patient who does NOT own this document
curl -w "\n%{http_code}" \
-H "Authorization: Bearer <other-patient-token>" \
http://localhost:8080/fhir/DocumentReference/doc-456
# Expected: 403 Forbidden
Verify pre-signed URL expiration:
- Read the DocumentReference and copy the pre-signed URL from the response.
- Open the URL in a browser - the image loads.
- Wait for the expiration timeout (120 seconds by default).
- Refresh the browser tab - Azure returns a 403 error. The URL is no longer valid.
Use debug mode to trace a failing request:
curl -H "Authorization: Bearer <patient-token>" \
-H "X-Fire-Arrow-Debug: true" \
http://localhost:8080/fhir/DocumentReference/doc-456
The response includes a detailed trace of which rules were evaluated and why access was granted or denied. See Authorization Debug Mode for how to read the output.
Configuration recommendations
| Setting | Recommended value | Notes |
|---|---|---|
presigned-url.expiration-seconds | 120 | Good default for most use cases. Increase to 300 for radiology viewers. |
max-file-size | 10485760 (10 MB) | Sufficient for clinical photos. Increase for radiology images or video. |
allowed-content-types | ["image/jpeg", "image/png", "application/pdf"] | Restrict to expected types in production. Leave empty during development. |
auto-create-container | false (production) / true (development) | Pre-create containers in production with appropriate access policies. |
credential-mode | managed-identity (production) | Avoid connection strings in production; use Azure managed identity. |
Further reading
- Binary Storage - full technical reference for binary storage configuration.
- Custom Operations - reference for
$binary-uploadand other operations. - Authorization Concepts - how the rule-based authorization system works.
- LegitimateInterest Validator - organization-based access control for practitioners.
- PatientCompartment Validator - patient-scoped access control.
- Authorization Debug Mode - troubleshooting authorization issues.
- Web UI: Resource Browser - managing resources through the web interface.