This page names what a FerroFED gateway protects, the trust boundaries it
sits on, who can attack each boundary and from where, and what stops them.
Each mitigation names the setting, the check or the test that provides it.
Each risk the gateway does not close is named too, with the issue that
tracks it or the control your deployment has to supply. The
hardening guide turns this page into a checklist.
The method is STRIDE per boundary: spoofing, tampering, repudiation,
information disclosure, denial of service and elevation of privilege. The
specification’s own security model is §13: every client is authenticated
(§13.1, N25), the gateway authenticates to every node and tells it who asks
(N24), each node makes its own access and consent decision (§13.2, N26, N27),
and no directly identifying patient identifier reaches a node (§5.4.1, N33).
§13.4 leaves five decisions to each deployment, which
The §13.4 deployment decisions answers
for the gateway. No specification governs the rest of this page: our own
design.
it names a person; it may reach the identity services and nothing else
Clinical data in transit
the answers a node releases and the writes a client sends
The caller’s identity and token
the token admits its holder at the gateway; the identity is what each node audits and decides on
The onward credentials and the signing key
they make the gateway’s requests acceptable to every node; the signing key vouches for every caller
Routing state
the resolution bindings, the ehr_id index and the creating_system_id routes decide which node a follow-up reaches; a wrong route sends a request to another patient’s record
The audit records
the IHE audit trail and the access records name patients and callers, and are evidence after the fact
The configuration and the registry
they decide whom the gateway trusts and where it sends data
Availability
one federated query becomes a request to every member, so the gateway’s load is every CDR’s load
a request without a valid token, a token signed with none or an HMAC key, a token for another audience or from an untrusted issuer
P1
every request to {base}/v1/ and OPTIONS {base}/ is verified before anything else is read: at+jwt, ES256, ES384, PS256 or RS256, a trusted iss, this gateway’s aud, exp and nbf (RFC 9068, RFC 8725 §3.1, §3.2); there is no unauthenticated mode
a caller reads another patient, or an operation its scopes do not cover
P2
SMART on openEHR scopes per route; system/aql-* only for backend_clients; a patient/ grant admits nothing unless its issuer is bound to one member, and then only that patient’s {node, ehr_id} pairs; purpose of use required
a client application with no user, or a user authenticated below the level the deployment requires, reading patient data
P1
a token whose sub is its client_id, or that only system/ scopes cover, reaches no patient data unless its issuer declares its client tokens as acting for the professional the token names; an issuer’s [auth.issuer.assurance] refuses a token below its least level (Regulation (EU) 2025/327 Annex II 3.1; RFC 9470 §3)
an AQL query that smuggles the patient identifier, a second patient, or text around the subject
P2
the query is parsed and the node query is printed from the rewritten syntax tree; a subject the rewrite cannot consume exactly is 400 before any dispatch (§5.4.3)
an undeclared header or query parameter, or a declared value of the wrong kind, forwarded to a node
P2
a routed request carries only what its ITS-REST operation declares; Accept, Content-Type and Prefer are composed by the gateway; a value of the wrong kind is 400 with nothing sent
the gateway records each query, read and write that reached a node with the verified caller and its request id, before the answer leaves; the request id travels to every node with the signed caller token, and each node audits who asked (N24)
the token or the patient identifier read on the hop between the proxy and the gateway
P3
the listener speaks plain HTTP by default: keep that hop inside one host or pod, protect it with a mesh, or set [server.tls] with a client_ca_file that admits the proxy alone
The health family, GET {base}/ and GET {base}/.well-known/jwks.json are
open by design, and none of them names a member. GET {base}/ names the
product version; restrict it at the proxy if your network should not learn
it. The dependency report, which names every member endpoint id and its last
observed state, is served only to an operator, at
GET {base}/operator/dependencies behind the operator scope.
the patient identifier reaches a node in the query, the path, the query string, a header or the caller claims
P4
the rewrite strips or refuses the subject; the outbound gate re-reads every finished request, the caller claims included, and stops one that carries a consumed value (§5.4.1, N33, CP-2, CP-26, CP-38)
app/ferrofed-engine/tests/it/gate.rs; the adversarial track 10, app/ferrofed-server/tests/it/track10/
S
a node replays the caller’s token at another node
P4
the caller’s token is never forwarded; each node gets its own onward credential and a caller token signed for its endpoint id alone, valid 60 seconds
each node gets the caller’s scopes and purposes of use and decides what to release (§13.2, N26, N27)
the node’s own enforcement
D
a slow or failing node holds the gateway
P4
per-node and overall budgets; at most max_in_flight_per_node requests to one member at once; a silent member is named in the answer and fails it under the all-or-nothing default (§11)
a node answers without end, or with a body too large to hold, so the gateway buffers it on every request
P4
the gateway reads at most max_node_answer_bytes of one answer from a node or its token endpoint and drops the rest unread; the member is node-error, named in the answer, and fails it 424 under the all-or-nothing default (§11.1, §11.4)
a stale binding after a merge at the identity source
P4
each binding lives binding_ttl_ms after the last resolution that returned it; [pmir] drops the stale ones as the change arrives, on the replica that receives it
federation.binding_ttl_ms, [pmir]
D
an identity service that does not answer
P4
budgets per exchange; a resolution that fails is never a pass: under the all-or-nothing default the query fails 424
a remote peer runs a write action, such as the stored-query distribution
P7
every write action needs a token that client authentication verifies and that carries its issuer’s operator_scope; the listener is off unless [metrics] listen is set, on loopback unless allow_remote = true, and never under the base path
each admitted write action is logged under ferrofed::security before it has any effect (admin-write-admitted, counted) and when it ends with its outcome (admin-write-finished, abandoned when it never answered), with the operator’s issuer and subject, the action and the time; no log filter quiets the target
app/ferrofed-server/tests/it/admin_record.rs, stored_redistribution.rs; carried by the deployment: keeping the security log
I
anyone who reaches the port reads the metrics
P7
no label carries a request value; off loopback the gateway refuses to start outside development unless a scrape token or a client CA authenticates the scrape; the Kubernetes example opens the port to Prometheus alone
a patient identifier or a caller in a log line, a metric or a span
P6
the request log carries no body, AQL text, header value or path; labels come from closed sets; spans carry route templates, ids and counts; the trace id is the gateway’s own
app/ferrofed-server/tests/it/request_log.rs, metrics/hygiene.rs, traces/; track10 asserts the logs clean
I
the audit spool read on disk
P9
the spool is 0700 with files 0600, and the gateway refuses to start when it is open to other users
app/ferrofed-server/tests/it/audit_repository.rs; carried by the deployment: an encrypted volume
T, R
audit records lost with a spool
P9
records are delivered in order from disk; a lost spool loses them for good, and audit_feed or audit_repository reads degraded while records wait
Losing a spool; carried by the deployment: durable storage per replica
I
metrics or spans pushed in cleartext
P3
OTLP is plain gRPC: run the collector beside the gateway; a credential in the URL is refused outside development
every federated query, stored-query execution, routed read and routed write that reached a node is recorded with the verified caller, the patient, each ehr_id and each endpoint asked; the record is stored before the answer leaves, and an access whose record cannot be stored is 503 access-unrecorded with none of the data; outside development a gateway with a registry refuses every [audit] destination but repository; the node audits the conveyed caller too (N24)
The access log; [audit], [access_log]; app/ferrofed-server/tests/it/access/, feed_audit/config.rs
an issuer compromised: any token it signs is admitted
P5
none in the gateway: a trusted issuer is trusted for every caller it vouches for
carried by the deployment: a short trust list, per-issuer backend_clients, demographic_clients and operator_scope, and removing an issuer at once; reloading [auth] without a restart is #636
a configuration that weakens transport or resolves from a static table
P9
the development profile alone admits [dev] and cleartext credentials, takes a restart to change, and prints a red notice; config check exits 78 on a refused file
profile; app/ferrofed-server/tests/it/transport/
S
the signing key leaks
P9
the key is a file; rotation publishes the next key ahead of signing with it
The gateway cannot close these on its own. Each needs a decision or a
control from you, or an issue that is open:
The proxy-to-gateway hop is cleartext unless you set [server.tls].
Bearer tokens and patient identifiers cross it. Keep it on one host or in
one pod, put a mesh with mutual TLS on it, or serve TLS on the listener
with a client CA that admits the proxy alone
(TLS on the listeners).
The development profile opens the admin listener’s write actions on
loopback. Under profile = "development" any process that reaches the
listener’s loopback address runs a write action without a credential.
Never serve real requests in that profile
(Metrics).
The access records live at your Audit Record Repository. The
gateway stores each record in the spool before it answers, and refuses
an access it cannot record, but who may read the records and how long
they are kept is the repository’s. FerroFED’s own review interface and
retention by origin and category are planned
(#521).
Bearer replay. An onward token or a caller token stolen at a node can
be replayed at that node until it expires (§13.4). DPoP or mutual TLS
narrows it for onward tokens; the caller token lives 60 seconds.
A compromised issuer is admitted for every caller it vouches for,
and changing [auth] takes a restart
(#636).
A node that ignores the conveyed caller releases what its own
credential allows. Admission is where you check that each node verifies
the token (Admitting a node).
The identity service is trusted for its links. A wrong link routes a
query, or a bound patient grant, to another patient’s EHR.
Per-replica routing state. A PMIR change reaches one replica, and the
others route on a stale binding for up to binding_ttl_ms
(Several replicas).
The audit spool names patients; its encryption at rest and its
survival are the volume’s.
The console’s session ends when the operator’s access token
expires, and a refresh is
#643; its
sign-in has no per-client rate limit of its own.
Telemetry export is plain gRPC to the collector
(#644).
Egress. The Kubernetes example leaves egress open so the gateway
reaches its members and services wherever they are; restricting it is
yours (#638 covers
the packaging).