Skip to content
LambogeniusDocs

HL7 Edge Gateway

Security and HIPAA

Console users, roles and single sign-on, the audit trail, encryption at rest, how they map to the HIPAA Security Rule, and who may call the gateway and how it signs in to destinations.

Runs onWindows · Linux · macOS · DockerFree developer license for test traffic. Production traffic needs a paid license.

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:

RoleMay
viewerSee routes, queues, dead letters, nodes and metrics.
operatorAlso pause, resume and Retry now; move, requeue and delete messages; use the Filter and Transform testers.
auditorSee what a viewer sees, and read and export the audit trail.
adminEverything, 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-password asks 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_timeout without activity (the console's own refreshes don't count) or after max_lifetime. The cookie is HttpOnly, SameSite=Strict and Secure.
  • HTTPS: signing in needs it. Serve the console with [admin] tls, or set behind_tls_proxy = true when a load balancer or ingress ends TLS in front of it.
  • [admin] token still works, as an admin API token named admin-token that 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 text
Entra ID

Register 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 SIEM

Auditors 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 instead

Encryption 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.

SafeguardHow 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 typeCallers sendGood for
jwtAn OAuth2 / OpenID Connect access token (Entra ID, Okta, Auth0, Keycloak…)Organizations with an identity provider; authorization by scope or role
api_keyA key in a header (X-API-Key by default); each caller has its ownPartners without an identity provider; revoke one without affecting the rest
bearerOne shared tokenA single caller
basicA username and passwordLegacy 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"]
Entra ID

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: api and refresh_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.