Skip to content

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

Published 10 min readAvailable inENFRAR — Not translated yet
  • zero-trust
  • iomt
  • security
  • nodejs
  • jwt
Diagram of VIGIE’s decision chain: a glucose sensor’s request goes through the PEP, then JWT, RBAC and ABAC checks, and ends in PERMIT. Median latency rises from 0.62 to 1.08 ms.
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:

ModuleIts job
AuthenticationProves the device is the one that was enrolled, issues short-lived tokens
Policy engineDecides each request from the role, the attributes and a trust score
Context managerTurns location, time, behaviour, token age and patient state into that score
MonitoringLogs 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:

  1. Check the serial number and supplier reference against the purchase documents.
  2. Create the device’s identity: a deviceId and 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.
  3. Record the expected fingerprint: type, model, firmware version, protocol and gateway.
  4. 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:

AttributeWeight
Recent behaviour (request rate, volume, recent failures)0.30
Network location0.25
Time of day against the usual profile0.15
Age of the authentication0.15
Fingerprint match0.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):

policyEngine.jsJS
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:

RuleConditionAction
R15 failed logins in 60 s for one deviceDevice suspended, alert
R2A revoked token is presentedImmediate alert, origin flagged
R3A jti is reused (replay)Device revoked, alert
R4A refresh token is reusedToken family revoked, alert
R5Request volume above 3× the device’s profileBehaviour score lowered, alert
R6Repeated requests outside the roleBehaviour 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:

TestScenarioExpectedResult
T1Legitimate two-factor loginToken issuedPassed
T2Spoofing with a forged proofHTTP 401Passed
T3Wrong device fingerprintHTTP 401Passed
T4Sensor trying to read (outside its role)HTTP 403Passed
T5Writing on behalf of another deviceHTTP 403Passed
T6Several weak context signals togetherHTTP 403Passed
T7Brute force (6 failures)Device suspendedPassed
T8Login from the suspended deviceHTTP 401Passed
T9Request without a tokenHTTP 401Passed
T10Forged tokenHTTP 401Passed
T11Normal legitimate requestHTTP 200, PERMITPassed

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:

LatencyWithout VIGIEWith VIGIEAdded
Median0.62 ms1.08 ms0.46 ms
Mean0.86 ms1.53 ms0.67 ms
95th percentile1.22 ms3.60 ms2.38 ms
99th percentile8.97 ms11.32 ms2.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.

Oussama Malloud

Oussama Malloud

Full-Stack & Mobile Engineer · Rabat

Have a project, or a question about this note?

Request access

More notes