// NOTE 003
Zero Trust for medical IoT: building VIGIE, and what it costs per request
For my final-year Master’s project I designed and built VIGIE, a Zero Trust framework that checks every request a connected medical device makes, from its identity to the patient’s clinical state. The Node.js prototype passed all 11 attack and access tests and added about half a millisecond per request.

On this page 8 sections
› section 1 / 8
Why the hospital network can’t be trusted
A glucose monitor sends its readings over MQTT. An infusion pump takes its settings from the nurses’ station. An imaging system drops its scans into the hospital information system. Together, devices like these form the Internet of Medical Things (IoMT), and many hospital networks still protect them the old way: a firewall around the building, and implicit trust for anything already inside.
That model breaks on how medical devices are used. They move between parts of the network, sometimes all the way to the patient’s home. They speak different protocols (MQTT, CoAP, HTTP/2) with uneven cryptographic support. Most are too small to run the security agent a laptop would. Their protocols can encrypt the channel, but nothing in them proves that the device at the other end is the real sensor. Once an attacker is past the perimeter, they move freely.
The stakes also go beyond leaked records. A compromised infusion pump can change a treatment in progress.
The question I worked on
Zero Trust, as NIST SP 800-207 defines it, drops the idea of a trusted inside: every request is authenticated, authorised and monitored, wherever it comes from. Published work had already mapped the techniques and applied parts of them to healthcare. What I couldn’t find was one runnable chain covering everything from enrolling a device to detecting that it went rogue, and very few designs used the patient’s clinical state in the access decision.
So my final-year Master’s project asked one question: can the parts of a Zero Trust architecture run in front of constrained medical devices without slowing down care?
My answer is VIGIE. In French, vigie is the lookout post that never stops watching. It also stands for Vérification d’Identité et Gouvernance Intelligente des Échanges.
From threats to ten requirements
I started from the threats, sorted with the STRIDE model: identity spoofing, replayed tokens, unauthorised access and privilege escalation, man-in-the-middle, and denial of service. Then I added what HIPAA and the GDPR require for health data, such as audit trails and breach notification within 72 hours.
That gave ten numbered requirements, ES-01 to ES-10. Each one maps to a design choice, then to a code module, then to a test. Two of them protect nothing on their own and decide whether the design is usable at all:
- ES-09: no software agent on the device, and only light cryptography on its side.
- ES-10: a latency overhead that is bounded and measured on a test bench. A framework that broke either rule could be secure on paper and useless at a bedside.
One gate, four modules
Every device request goes through a single entry point, the policy enforcement point (PEP) in NIST terms. Behind it, four modules share the work:
| Module | Its job |
|---|---|
| Authentication | Proves the device is the one that was enrolled, issues short-lived tokens |
| Policy engine | Decides each request from the role, the attributes and a trust score |
| Context manager | Turns location, time, behaviour, token age and patient state into that score |
| Monitoring | Logs every decision, spots anomalies, can suspend or revoke a device |
Where the first trust comes from
Token-based systems often skip the question of where the first trust comes from. In VIGIE it comes from a four-step enrolment done by the security administrator when a device arrives:
- Check the serial number and supplier reference against the purchase documents.
- Create the device’s identity: a
deviceIdand a secret (a client certificate if the device can carry one, a dedicated symmetric key if it can’t), loaded over a local channel, never over the production network. - Record the expected fingerprint: type, model, firmware version, protocol and gateway.
- Assign a role based on the device’s clinical function. Logging in then takes two factors: proof of the secret and a matching fingerprint. Devices with a certificate sign a challenge. Devices limited to symmetric crypto compute an HMAC over a one-time challenge, which on a common 32-bit microcontroller should take tens to hundreds of microseconds and a few hundred bytes of memory. That figure is an estimate from the primitive’s published performance; measuring it on real hardware is still on my list.
A successful login returns a JWT that lives 5 minutes, with a unique ID (jti), the device’s role and its scope. The design signs tokens with RS256, so any component can verify a token without being able to issue one. The prototype uses HS256 as a documented shortcut.
Refresh tokens rotate, and each one works once. If an attacker steals one and uses it, the real device will sooner or later present its old copy. That double use can’t happen in normal operation, so it revokes the whole token family and raises an alert. The defence produces its own evidence of theft.
The device’s status is also checked on every request. Marking a device as suspect disables all its tokens at once, including the ones that are still cryptographically valid.
Deciding each request
The policy engine runs four checks in a fixed order: the token, the role (RBAC), the attribute conditions (ABAC), then the trust score against the resource’s threshold. Anything not explicitly allowed is refused, and the first refusal ends the evaluation. The cheapest checks run first, so obvious junk is rejected at the lowest cost, which also helps under a flood of requests.
Roles follow least privilege, with one rule that matters a lot: a glucose sensor may write glucose readings, but only its own. Without that restriction, one compromised sensor could falsify readings for every patient. The security administrator’s role manages policies and revokes devices, and it has no access to clinical data.
ABAC adds the circumstances. A nurse, for example, can read measurements only during their shift and from the hospital network.
The trust score is a weighted sum of five attributes, each scored between 0 and 1:
| Attribute | Weight |
|---|---|
| Recent behaviour (request rate, volume, recent failures) | 0.30 |
| Network location | 0.25 |
| Time of day against the usual profile | 0.15 |
| Age of the authentication | 0.15 |
| Fingerprint match | 0.15 |
Behaviour weighs the most because it is the earliest signal of compromise and the hardest to fake. Each resource has its own threshold: 0.8 for the patient record, 0.6 for writing measurements, 0.4 for reading technical references.
The decision has three outcomes. Between PERMIT and DENY there is RESTRICT: when the score lands just under the threshold (within 0.15), a read is still allowed in a reduced form and a low-severity alert goes out. A sensor that drifts a little from its profile is more likely faulty than hacked, and cutting it off would mean losing a patient’s monitoring on a weak suspicion.
Here is the core of that decision in the prototype (abridged, comments translated):
authorize({ subject, role, action, resource, ctx = {} }) {
// Step 2: RBAC (the token was already checked by the PEP middleware)
if (!this._rbacAllows(role, resource, action, subject, ctx))
return { decision: 'DENY', reason: 'rbac_refus', http: 403 };
// Step 3: ABAC conditions
const abac = ABAC[role];
if (abac && !abac({ ...ctx, action }))
return { decision: 'DENY', reason: 'abac_refus', http: 403 };
// Step 4: contextual score against the resource threshold
const scoreRes = this.context.computeScore(subject, ctx);
let threshold = this._thresholdFor(resource);
if (ctx.patientState === 'critical') {
if (action === 'read') threshold = Math.max(0.3, threshold - 0.2);
if (action === 'write' || action === 'command')
threshold = Math.min(0.95, threshold + 0.1);
}
if (scoreRes.score >= threshold)
return { decision: 'PERMIT', score: scoreRes.score, breakdown: scoreRes.breakdown };
if (scoreRes.score >= threshold - config.trust.degradedMargin && action === 'read')
return { decision: 'RESTRICT', score: scoreRes.score, restricted: true, http: 200 };
return { decision: 'DENY', reason: 'score_insuffisant', score: scoreRes.score, http: 403 };
}Every decision is logged with the full breakdown of its score, so a refusal can always be explained to an administrator or an auditor.
The patient’s state changes the rules
The patientState lines above are the part of VIGIE I care about most. When the hospital system flags a patient as critical, two thresholds move in opposite directions:
- Care staff get easier read access to that patient’s data, even outside their usual hours (the threshold drops by 0.2). This is a break-glass rule: every use of it is logged with its own marker and triggers a notification.
- Commands to actuators, like changing a pump’s setting, get harder to pass (the threshold rises by 0.1). In an emergency, reading data fast saves time, while a forged pump command can kill. The framework has to make the first easier and the second harder at the same moment.
Watching, and acting on what it sees
Every security event becomes a JSON log entry: timestamp, device, action, resource, decision, score breakdown, reason, origin address and token ID. Each entry also carries the SHA-256 hash of the previous one, so editing or deleting an old entry breaks the chain in a way a simple pass detects. Logs are written asynchronously, so they take nothing from the latency budget.
The alert module reads that same log stream and applies sliding-window rules:
| Rule | Condition | Action |
|---|---|---|
| R1 | 5 failed logins in 60 s for one device | Device suspended, alert |
| R2 | A revoked token is presented | Immediate alert, origin flagged |
| R3 | A jti is reused (replay) | Device revoked, alert |
| R4 | A refresh token is reused | Token family revoked, alert |
| R5 | Request volume above 3× the device’s profile | Behaviour score lowered, alert |
| R6 | Repeated requests outside the role | Behaviour score lowered, alert |
Automatic action is kept for signals with few false positives and a high cost of doing nothing. A suspended device can be cleared by an administrator, because a false alarm shouldn’t take a medical device out of service for long. A revoked device can only come back through a full re-enrolment with a new secret.
What the tests showed
The prototype runs on Node.js 20 in a single process. A second Node.js process plays the sensors and the attacker, and talks to the framework through the same network interfaces a real device would use. I wrote 11 tests, each tied to a requirement, each with an expected result checked automatically:
| Test | Scenario | Expected | Result |
|---|---|---|---|
| T1 | Legitimate two-factor login | Token issued | Passed |
| T2 | Spoofing with a forged proof | HTTP 401 | Passed |
| T3 | Wrong device fingerprint | HTTP 401 | Passed |
| T4 | Sensor trying to read (outside its role) | HTTP 403 | Passed |
| T5 | Writing on behalf of another device | HTTP 403 | Passed |
| T6 | Several weak context signals together | HTTP 403 | Passed |
| T7 | Brute force (6 failures) | Device suspended | Passed |
| T8 | Login from the suspended device | HTTP 401 | Passed |
| T9 | Request without a token | HTTP 401 | Passed |
| T10 | Forged token | HTTP 401 | Passed |
| T11 | Normal legitimate request | HTTP 200, PERMIT | Passed |
T7 and T8 together show the closed loop: the device suspended by rule R1 is refused afterwards, even with credentials that would otherwise still be valid.
For latency, I compared a write going through the whole chain (token, RBAC, ABAC, score, logging) with a control endpoint that has no checks at all. Three runs of 3,000 requests each, after 50 warm-up requests:
| Latency | Without VIGIE | With VIGIE | Added |
|---|---|---|---|
| Median | 0.62 ms | 1.08 ms | 0.46 ms |
| Mean | 0.86 ms | 1.53 ms | 0.67 ms |
| 95th percentile | 1.22 ms | 3.60 ms | 2.38 ms |
| 99th percentile | 8.97 ms | 11.32 ms | 2.35 ms |
In relative terms the median goes up by about 75%, which sounds heavy. In absolute terms it is less than half a millisecond, on a baseline that does almost nothing. Remote patient monitoring usually counts its latency in tens or hundreds of milliseconds once a real network is involved, so a sub-millisecond overhead disappears in it.
Under 5,000 concurrent requests, the unoptimised single process handled about 600 to 700 requests per second. A telemetry sensor publishes about once per second, so one instance can serve several hundred devices. Tokens are validated locally with no shared state, so more instances can sit behind a load balancer.
I also tested the log chain. The built-in check confirmed every entry, including under concurrent writes. After I altered one entry on purpose, the check pointed to exactly that position.
The test that found a bug
T6 didn’t pass the way I first wrote it. I expected a single write from an unknown network to be refused. It was allowed: the device was authenticated, behaving normally and had the right fingerprint, so its score stayed above the threshold.
That turned out to be the right behaviour. One odd signal shouldn’t cut off a legitimate medical device. The test now combines an unknown network, an unusual hour and an abnormal request volume, and that combination is refused.
Digging into it also exposed a real bug. Because of an ambiguous prefix match, write requests were getting the lower threshold meant for reads. I fixed it, and it’s the reason I now prefer tests that run against the real system: the ones that only confirm what the designer expected teach nothing.
What this doesn’t prove yet
› NOTE
VIGIE is a proof of concept, tested in a controlled environment. These limits are part of the result.
- Devices, attacks and network are simulated, and latency was measured over local HTTP, so absolute numbers will be higher on a real network.
- TLS was off on the test bench, so encrypted transport (ES-05) is a deployment requirement and remains unverified here.
- The replay rules (R3, R4) and the flood rule (R5) are implemented but had no dedicated test scenario yet.
- The score weights and thresholds are reasoned values, not tuned on field data. They are configuration, so a pilot could adjust them without code changes.
- I wrote the attacks against my own system. A third-party audit or automated scanning would be the honest next step.
- The prototype signs tokens with HS256 instead of the RS256 in the design, and I didn’t benchmark VIGIE head to head against similar systems.
What I’d build next
The first steps are already scoped: plug in a real MQTT broker and a CoAP endpoint, run the replay and flood scenarios, and turn TLS on so ES-05 becomes a tested property. After that, a pilot with real devices is the only way to calibrate the trust score and measure false positives. The sliding-window rules could then be backed by a model trained on each device’s traffic, to tell a failing sensor from a compromised one. For the smallest devices, Ascon, the lightweight cryptography NIST standardised in SP 800-232, would bring strong authentication to sensors that can barely afford an HMAC today.
