SMART on FHIR scope validation at the API gateway: a step-by-step workflow builder guide
Validate SMART on FHIR scopes at the API gateway layer — no custom middleware. Step-by-step workflow builder guide with real jwt_node and scope config.
- healthcare
- fhir
- hipaa
- workflow-builder
- compliance
Healthcare platform teams building FHIR R4 APIs face a specific challenge that grows harder to manage as API consumers multiply: SMART on FHIR scope validation. The SMART on FHIR specification requires that authorization servers issue scopes — patient/*.read, launch/patient, user/Observation.write — and that the resource server checks those scopes before serving data. In theory this is clean. In practice the scope check ends up scattered across backend services, inconsistently implemented, and invisible to the security team.
By the time a health system has a patient portal app, a clinician mobile app, a data analytics integration, and three third-party health apps all calling the same FHIR endpoints, the absence of a consistent scope boundary becomes a compliance liability. The question is not whether SMART scopes should be validated. They must be. The question is where: in every backend service independently, or at a single controlled gateway layer that logs every decision.
The SMART on FHIR API gateway is the right place to enforce that boundary.
Why scope validation in application code breaks down
The default path most teams take is to validate SMART tokens inside each FHIR server or microservice. This works for a single service. It breaks down when you have multiple FHIR resources — Patient, Observation, Condition, Encounter — each implemented in different services by different teams with no shared validation logic.
Kong's JWT plugin can check token signature and expiry at the gateway, but routing decisions based on token claims require Lua scripts that need testing and maintenance across Kong versions. AWS API Gateway Lambda authorizers add a function invocation on every request, additional latency, and another piece of infrastructure to operate. MuleSoft's DataWeave approach is capable but puts scope policy inside XML configuration that is not visible to your compliance team without opening the deployment.
None of these approaches give you a single place where a compliance officer can see, for every request: what scope was required, what scope was presented, what the gateway decided, and which registered client made the call. That combination — enforceable policy plus full audit trail — is what HIPAA's Security Rule access control requirements actually need from your API layer.
The workflow builder approach for SMART on FHIR
Zerq's workflow builder lets you assemble this logic in a visual no-code flow that applies to one or more API collections. Each node is a discrete step with a documented config shape and explicit branching behaviour. The scope validation workflow for a FHIR endpoint uses four node types in sequence:
jwt_nodeinverifymode: checks the token signature against the authorization server's JWKS endpoint, validatesissandaudclaims, and branchesvalidorinvalidcondition_node: checks the decoded payload for the required scope and routes to an allow or deny branchresponse_node: returns a structured FHIROperationOutcomeerror on the deny pathproxy_node: forwards the validated request to the FHIR backend on the allow path
The result is a linear, auditable decision chain. Every request that passes through is logged with full headers, gateway decision, and client identity stored in your own MongoDB instance. Every request that fails scope validation generates a log entry with the client ID, path, timestamp, and rejection reason.
Building the SMART scope validation workflow: step by step
This guide validates the patient/*.read scope on a FHIR Patient collection. Adjust the scope string to match what your authorization server issues.
Step 1: Open the workflow canvas for your FHIR collection
In the Zerq management UI, navigate to Collections and open the collection that wraps your FHIR Patient endpoint. Click Workflow in the collection settings. If this collection currently uses a direct proxy, the canvas opens with an http_trigger node and a single proxy_node already connected. You will insert the validation nodes between them.
Step 2: Add and configure the jwt_node
Drag a JWT node from the node palette onto the canvas. Connect it between the http_trigger output and the existing proxy_node — you will re-wire the proxy_node connection in a later step.
Set the node configuration:
{
"id": "n_jwt_verify",
"type": "jwt_node",
"config": {
"operation": "verify",
"method": "jwks",
"jwks_url": "https://auth.health-system.example.com/.well-known/jwks.json",
"issuer": "https://auth.health-system.example.com/",
"audience": "fhir-api"
},
"inputs": {
"token": "{{ $json['http_trigger'].request.headers.Authorization[0].replace('Bearer ', '') }}"
}
}
The jwks_url points to your SMART authorization server's public key endpoint. The issuer and audience values must match what your auth server places in issued tokens — check your server's /.well-known/smart-configuration document for the correct values.
The token input expression strips the Bearer prefix before passing the raw JWT to the verification node. Without this, the node receives the full header value and fails signature verification. Wire the invalid output to a reject response node (step 4). Wire the valid output to the condition node (step 3).
Step 3: Add the condition_node for scope checking
Drag a Condition node and connect it to the valid output of the jwt_node.
{
"id": "n_scope_check",
"type": "condition_node",
"config": {
"conditions": [
{
"condition": "$json['n_jwt_verify'].payload.scope && $json['n_jwt_verify'].payload.scope.split(' ').some(s => s === 'patient/*.read' || s === 'patient/Patient.read')",
"output": "scope_ok"
}
],
"default_output": "scope_missing"
}
}
SMART on FHIR scopes are space-delimited in the scope claim. The split(' ') call produces an array that some() searches. The condition accepts both the wildcard patient/*.read and the specific patient/Patient.read form — different SMART authorization servers emit different scope granularity, so accepting both avoids rejecting valid tokens from common auth server implementations.
Conditions in the condition_node use plain JavaScript expressions referencing upstream outputs via $json. Do not wrap them in {{ }} template syntax — that is the expression format for inputs fields, not condition fields.
Wire scope_ok to the proxy_node. Wire scope_missing to a reject response node.
Step 4: Add response_node for each rejection path
Add two Response nodes: one for token verification failure (from the jwt_node invalid branch), one for scope failure (from the condition_node scope_missing branch).
Token verification failure — 401:
{
"id": "n_reject_auth",
"type": "response_node",
"config": {
"status": 401,
"headers": {
"Content-Type": "application/fhir+json",
"WWW-Authenticate": "Bearer error=\"invalid_token\""
},
"body": {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "security",
"details": { "text": "Token verification failed" }
}
]
}
}
}
Scope validation failure — 403:
{
"id": "n_reject_scope",
"type": "response_node",
"config": {
"status": 403,
"headers": {
"Content-Type": "application/fhir+json"
},
"body": {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"details": { "text": "Insufficient scope: patient/*.read required" }
}
]
}
}
}
Returning an OperationOutcome resource keeps the error shape consistent with the FHIR specification. Clients built to the standard parse this correctly. Returning a plain JSON object or an HTTP status with no body will break FHIR-conformant clients that expect a structured error.
Step 5: Connect the proxy_node on the allow path
Wire the scope_ok output of the condition_node to the existing proxy_node. The proxy_node forwards the validated request to your FHIR backend using the upstream URL configured on the collection.
Add a separate response_node on the proxy_node error branch to handle upstream failures:
{
"id": "n_upstream_error",
"type": "response_node",
"config": {
"status": 502,
"headers": { "Content-Type": "application/fhir+json" },
"body": {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "transient",
"details": { "text": "Upstream FHIR server unavailable" }
}
]
}
}
}
Keeping upstream errors on a separate path makes log filtering useful: a compliance team reviewing 403 entries knows those are authorization failures, not network issues.
Step 6: Save and enable
Click Save to store the workflow in draft state. The workflow is versioned — the management UI records who saved each version and when. This creates the audit trail for configuration changes separately from the request-level traffic logs.
Click Enable to activate the workflow. From this point, every request to this collection passes through the scope validation chain.
What the request log captures for HIPAA compliance
Every request generates a structured log entry stored in your MongoDB instance. For a scope-rejected request, the entry includes:
| Field | Example value |
|---|---|
timestamp | 2026-06-18T09:14:22.371Z |
method | GET |
path | /fhir/r4/Patient/patient-001 |
status_code | 403 |
client_id | analytics-vendor-uuid |
profile_id | vendor-prod-profile-uuid |
collection | fhir-patient-api |
client_ip | 10.0.4.22 |
latency_ms | 12 |
request_headers | full Authorization header and all request headers |
The client_id and profile_id fields identify which registered application made the request. Because every API consumer in Zerq is a named client with a profile, the log record connects the technical event (a 403 on a FHIR path) to a business entity (the analytics vendor).
For HIPAA compliance, both successful data access and failed access attempts are auditable events under the Security Rule's access control and audit control standards. The 403 log entries document that an application attempted to access patient data without the correct authorization, and that the gateway blocked it. This is exactly the record an auditor or OCR investigator needs.
Request logs are stored in your own MongoDB. They never transit Zerq's systems. You set the retention period in gateway settings — most healthcare organisations target 6 years to align with HIPAA record retention requirements.
You can filter and review logs directly in the management UI under Logs, or export them to your SIEM via the log API. The observability layer supports full-text search on request and response body, so you can filter by patient ID, FHIR resource type, or specific error text without building a custom extraction pipeline.
Applying scope policies across multiple FHIR resource types
The workflow above applies to one collection. A health system typically has separate FHIR collections for Patient, Observation, Condition, Encounter, and DiagnosticReport. Each resource type may require a different scope.
The cleanest approach at the start is one workflow per collection, with scope validation specific to each resource type:
| Collection | Required scope |
|---|---|
| fhir-patient | patient/Patient.read |
| fhir-observation | patient/Observation.read |
| fhir-condition | patient/Condition.read |
| fhir-encounter | patient/Encounter.read |
Each collection's workflow is independently versionable and testable. You can deploy them one at a time, validate that existing consumers have the correct scopes before enforcing, and roll back a single collection if a scope rule causes an unexpected rejection.
If you want a single workflow to govern multiple resource types, use a switch_node that routes by path prefix to a resource-specific condition_node. That approach consolidates the config but makes individual resource policies harder to audit in isolation.
What this looks like for a health system
A regional health system has a FHIR R4 server backing three API consumers: a patient portal (issued patient/*.read), a clinician scheduling app (issued user/Patient.read user/Appointment.read), and a population health analytics vendor (issued only user/Observation.read).
Before the workflow was in place, the analytics vendor could request Patient demographics without error — the FHIR backend checked token validity but not scope. After deploying the scope validation workflow on the Patient collection, requests from the analytics vendor return 403 with an OperationOutcome body. The vendor's developers see a clear error message. The security team sees a log entry with the vendor's client ID, the path, and the timestamp.
At the end of each quarter, the compliance team filters request logs by collection and status code to produce an access review report: how many Patient requests succeeded, how many were rejected, and which clients drove each category. This takes minutes in the log viewer rather than a custom SIEM query. The security and access control layer means the same credentials, audit trail, and access profiles govern the analytics vendor's API key and the patient portal's OAuth session from a single management surface.
Closing
A SMART on FHIR API gateway that validates scopes at the boundary gives you three things that backend-level validation cannot: a single enforcement point that cannot be bypassed by any individual backend service, a full audit trail with client identity attached to every access decision, and configuration that a compliance team can review without reading application code. The jwt_node, condition_node, and response_node chain in Zerq's workflow builder is the complete mechanism — signature verification, claims-based routing, FHIR-shaped error responses, and structured logs in your own infrastructure. No Lua scripts. No Lambda authorizers. No custom middleware to maintain.
Zerq is an enterprise API gateway built for regulated industries — one platform for API management, AI agent access, compliance audit, and developer portal, running entirely in your own infrastructure. See how it works or request a demo to walk through your specific requirements.