The gateway carries PHI, so it has the technical safeguards the HIPAA Security Rule describes: named console users with roles, automatic logoff, an audit trail that shows when it was changed, and encryption of stored messages, in every edition. They help you meet the rule; compliance also takes your own risk analysis, policies, business associate agreements and hosting.
Console users and roles
People sign in to the console with single sign-on or with a user name and password; scripts and monitoring call the admin API with named API tokens. Each has a role:
| Role | May |
|---|---|
viewer | See routes, queues, dead letters, nodes and metrics. |
operator | Also pause, resume and Retry now; move, requeue and delete messages; use the Filter and Transform testers. |
auditor | See what a viewer sees, and read and export the audit trail. |
admin | Everything, including installing a license. |
Seeing message contents is a separate grant, view_payload, on top of allow_payload_view = true, so an operator can run the interface without seeing PHI. Every message view is in the audit trail.
[admin]
listen = "0.0.0.0:8443"
tls = { cert = "certs/console.pem", key = "certs/console.key" } # or behind_tls_proxy = true
allow_payload_view = true # message contents, for those granted view_payload
# People who sign in with a password: hl7-edge-gateway hash-password
[[admin.users]]
name = "jsmith"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"
role = "operator"
[[admin.users]]
name = "breakglass" # for when the identity provider is down
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"
role = "admin"
# Scripts and monitoring: hl7-edge-gateway hash-token
[[admin.api_tokens]]
name = "monitoring"
token_sha256 = "9f2c…"
role = "viewer"
[admin.session]
idle_timeout = "15m" # automatic logoff
max_lifetime = "12h"- Passwords are kept as Argon2id hashes;
hl7-edge-gateway hash-passwordasks for one (12 characters or more) without showing it. Five failed sign-ins in 15 minutes lock the name for 15 minutes. - API tokens come from
hl7-edge-gateway hash-token: the token goes to the script (Authorization: Bearer hegt_…), only its SHA-256 into the configuration. - Sessions are kept in the store, so every node of a cluster knows them and signing out ends them everywhere. A session ends after
idle_timeoutwithout activity (the console's own refreshes don't count) or aftermax_lifetime. The cookie is HttpOnly, SameSite=Strict and Secure. - HTTPS: signing in needs it. Serve the console with
[admin] tls, or setbehind_tls_proxy = truewhen a load balancer or ingress ends TLS in front of it. [admin] tokenstill works, as an admin API token namedadmin-tokenthat may see message contents.
Single sign-on (OpenID Connect)
With [admin.oidc], the console offers a sign-in button that sends people to your identity provider (Entra ID, Okta, Google, Keycloak and others) with the authorization code flow and PKCE. Their role comes from a claim in the ID token, through role_map; someone with no mapped role is refused. Multi-factor authentication and conditional access stay with your identity provider.
[admin.oidc]
issuer = "https://login.microsoftonline.com/<tenant id>/v2.0"
client_id = "<application (client) id>"
client_secret = "${HL7EDGE_OIDC_CLIENT_SECRET}"
public_url = "https://gateway.hospital.org:8443" # redirect URI: <public_url>/auth/oidc/callback
roles_claim = "roles" # the default
role_map = { "HL7Gateway.Admin" = "admin", "HL7Gateway.Operator" = "operator", "HL7Gateway.Auditor" = "auditor", "HL7Gateway.Viewer" = "viewer" }
view_payload = ["HL7Gateway.PHI"] # claim values that may see message contents
# label = "Sign in with Contoso" # the button's textRegister an app with the redirect URI https://<console>/auth/oidc/callback (Web platform) and a client secret, add app roles such as HL7Gateway.Operator, and assign them to users or groups under Enterprise applications. The roles arrive in the ID token's roles claim. Keep one local admin user for when the identity provider can't be reached.
Audit trail
The gateway records sign-ins and sign-outs, failed sign-ins and lockouts, sessions that timed out, every change made through the console or the API (with the route, the message and the outcome), every message view, refused requests, license changes, audit exports, and its own starts and stops with a fingerprint of its configuration. Each entry has the time, the node, who (name, how they signed in, role) and their address.
Entries are kept in the store (in a cluster, in a JetStream stream all nodes share) for retention, 6 years by default, the time HIPAA asks you to keep required documentation. Each node's entries are chained by SHA-256, so changing, removing or reordering one shows.
[audit]
retention = "6y" # the default
directory = "audit" # also daily audit-YYYY-MM-DD.jsonl files, e.g. for a SIEMAuditors and admins see the trail in the console's Audit card, filtered by person, action, route and time, and export it as CSV. Scripts use GET /api/audit and /api/audit.csv. To check the chain:
hl7-edge-gateway audit verify -c hl7-edge-gateway.toml
gw-east-1: 18422 entries (1..18422)
intact
gw-east-2: 17960 entries (1..17960)
intact
hl7-edge-gateway audit verify -c hl7-edge-gateway.toml --dir audit # the JSON-lines files insteadEncryption at rest
With encryption in [store], queued and dead-lettered messages are encrypted with AES-256-GCM before they are written, on the embedded store and in NATS JetStream, so neither the store's files nor a NATS backup holds PHI in the clear. Keys come from files or environment variables (Kubernetes secrets, Key Vault, Secrets Manager); the first key encrypts and the others still decrypt.
# hl7-edge-gateway generate-key > keys/2026-10.key
[store]
backend = "embedded"
path = "data"
encryption = { keys = [{ id = "2026-10", file = "keys/2026-10.key" }] }
# or from an environment variable:
# encryption = { keys = [{ id = "2026-10", env = "HL7EDGE_STORE_KEY" }] }The store remembers a fingerprint of each key it has seen. A node started with a different key under the same ID, or without the key the cluster encrypts with, refuses to start and says why, instead of failing on every message. To rotate keys without stopping traffic:
# 1. A new key first; the old one stays, to open what it encrypted. Restart each node.
encryption = { keys = [{ id = "2027-04", file = "keys/2027-04.key" }, { id = "2026-10", file = "keys/2026-10.key" }] }
# 2. Rewrite what the old key encrypted (embedded store: stop the gateway first).
hl7-edge-gateway store reencrypt -c hl7-edge-gateway.toml
# 3. Then the old key can go.
encryption = { keys = [{ id = "2027-04", file = "keys/2027-04.key" }], required = true }On JetStream, store reencrypt rewrites dead letters; the queues drain on their own. Messages stored before encryption was turned on stay readable until rewritten; required = true refuses them.
The HIPAA Security Rule, §164.312
How the gateway helps you meet each technical safeguard. What it doesn't cover is yours to address around it.
| Safeguard | How the gateway helps |
|---|---|
| Access control: unique user identification §164.312(a)(2)(i) | Named users, single sign-on identities and named API tokens; the audit trail names each one. |
| Emergency access procedure (a)(2)(ii) | A local admin user that works when the identity provider doesn't. The procedure for using it is yours. |
| Automatic logoff (a)(2)(iii) | Sessions end after idle_timeout (15 minutes by default) and max_lifetime. |
| Encryption and decryption (a)(2)(iv) | AES-256-GCM encryption of stored messages, with key rotation. |
| Audit controls (b) | The audit trail: kept 6 years by default, hash-chained, exported as CSV or JSON lines. |
| Integrity (c) | Stored messages are authenticated by AES-GCM, so a changed one is refused rather than delivered; acknowledgments confirm what each system received. |
| Person or entity authentication (d) | Single sign-on with your identity provider's multi-factor authentication; Argon2id passwords with lockout; OAuth2, API keys and client certificates for systems. |
| Transmission security (e) | TLS 1.2 and 1.3 for MLLPS, HTTPS, SFTP, Service Bus and NATS, with mutual TLS. See TLS. |
Who may send to the gateway
An http source can require one of these from its callers with auth. Mutual TLS (TLS) can be combined with any of them.
| auth type | Callers send | Good for |
|---|---|---|
jwt | An OAuth2 / OpenID Connect access token (Entra ID, Okta, Auth0, Keycloak…) | Organizations with an identity provider; authorization by scope or role |
api_key | A key in a header (X-API-Key by default); each caller has its own | Partners without an identity provider; revoke one without affecting the rest |
bearer | One shared token | A single caller |
basic | A username and password | Legacy senders |
OAuth2 / OpenID Connect tokens
The gateway fetches the issuer's signing keys from its discovery document (or jwks_url), caches them, and checks each token's signature, issuer, audience and expiry. required_scopes and required_roles decide which callers may use the route: a missing or invalid token gets 401, a valid token without the scope or role gets 403 with the reason. The caller's client ID is written to the log with each message.
[route.source]
type = "http"
listen = "0.0.0.0:8443"
path = "/hl7/orders"
tls = { cert = "certs/gateway.pem", key = "certs/gateway.key" }
allow_from = ["10.20.0.0/16"]
[route.source.auth]
type = "jwt"
issuer = "https://login.microsoftonline.com/<tenant-id>/v2.0"
audience = "<API app's client ID>"
required_roles = ["Hl7.Send"] # and/or required_scopes = ["hl7.write"]Register an app for the gateway's API with an app role such as Hl7.Send and set its manifest's requestedAccessTokenVersion to 2. Entra v2 tokens carry that app's application (client) ID as their audience, not its api:// URI. Assign the role to each calling app and grant admin consent.
API keys
[route.source.auth]
type = "api_key"
header = "X-API-Key" # the default
keys = { lab_a = "${LAB_A_KEY}", portal = "${PORTAL_KEY}" }Keys must be at least 16 characters. The key's name (lab_a) is logged as the caller.
IP allowlists
Every source, MLLP or HTTP, takes allow_from: addresses and CIDR ranges such as ["10.20.0.0/16", "192.168.1.5"]. MLLP connections from elsewhere are closed unread; HTTP requests get 403.
How the gateway signs in to destinations
Static credentials go in headers, for example an API key. For OAuth2 and Salesforce, set auth on an http destination: the gateway gets an access token, caches it and renews it before it expires. If the destination still answers 401, it gets a new token and tries once more.
OAuth2 client credentials
With a client_secret (sent as Basic auth, or in the form with client_auth = "post"), or with a private_key that signs a client assertion (private_key_jwt, RS384 by default). Add scope, audience or resource as the token endpoint needs.
[route.destination]
type = "http"
url = "https://api.example.org/hl7/results"
[route.destination.auth]
type = "oauth2"
token_url = "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"
client_id = "<client app ID>"
client_secret = "${RESULTS_CLIENT_SECRET}"
scope = "api://<API app ID>/.default"
# Or with a private key instead of a secret (private_key_jwt, as Epic backend services use):
# private_key = "keys/epic.pem"
# key_id = "edge-1"Salesforce
With username and private_key, the gateway signs in with the OAuth 2.0 JWT bearer flow as that user; with client_secret, with the client credentials flow. Requests go to your org's instance URL, keeping the path and query of url, for example an Apex REST class.
[route.destination]
type = "http"
url = "https://instance/services/apexrest/Hl7Inbound" # the host becomes your org's instance URL
content_type = "text/plain"
[route.destination.auth]
type = "salesforce"
client_id = "<consumer key>"
username = "hl7-integration@hospital.org"
private_key = "keys/salesforce.key"
# login_url = "https://test.salesforce.com" # sandboxes
# Client credentials instead of a key: client_secret = "${SF_SECRET}", with
# login_url = "https://<my domain>.my.salesforce.com" and no username or private_key.In Salesforce, create an External Client App (or a Connected App) for the gateway:
- OAuth scopes:
apiandrefresh_token, offline_access. Salesforce requires the second for the JWT bearer flow, though the gateway never uses refresh tokens. - JWT bearer flow: enabled, with the certificate that matches
private_key. - Policies: Permitted Users = "Admin approved users are pre-authorized", with the integration user's profile or permission set. Save them: until then Salesforce answers
app_not_found.