DEV Community

Arpit Mishra
Arpit Mishra

Posted on

Building a HIPAA-Compliant Telemedicine API: Architecture, Code, and Pitfalls

HIPAA-compliant" is one of those phrases that appears on every healthtech landing page and in almost no codebases. That's because HIPAA doesn't ship as a library you can npm install — it's a set of obligations (the Security Rule, the Privacy Rule, breach notification) that your architecture either satisfies or doesn't. This post translates those obligations into actual engineering decisions: how to structure a telemedicine API, what the code looks like, and the pitfalls that quietly turn "compliant" systems into breach reports.

We'll use Node.js/Express and PostgreSQL for examples, but every pattern here maps directly to Django, Spring Boot, or Go.

What HIPAA Actually Requires From Your Code

Strip away the legal language and the Security Rule demands four things from your system:

Access control — only authorized people see Protected Health Information (PHI), and only the minimum they need
Encryption — PHI is unreadable in transit and at rest
Audit trails — every access to PHI is logged: who, what, when
Integrity and availability — data can't be silently altered, and it survives failures

Everything below is one of these four, expressed as architecture.

Architecture Overview

A telemedicine API decomposes into services with very different PHI exposure:

┌──────────────┐ ┌──────────────────────────────────┐
│ Mobile/Web │────▶│ API Gateway (TLS termination, │
│ Clients │ │ rate limiting, JWT validation) │
└──────────────┘ └───────────┬──────────────────────┘

┌────────────┬───────────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐
│ Auth │ │ Patient │ │ Consult │ │ Video │ │ Audit │
│ Service │ │ Records │ │ Booking │ │ Session │ │ Logger │
│ (no PHI)│ │ (PHI!) │ │ (PHI) │ │ (PHI) │ │ (append- │
└─────────┘ └──────────┘ └─────────┘ └──────────┘ │ only) │
└───────────┘

The design principle: minimize which services touch PHI at all. Your auth service should know a user exists but nothing about their health. Your notification service should send "You have an appointment tomorrow" — never "Your cardiology appointment about arrhythmia is tomorrow." Every service that avoids PHI is a service you don't have to defend in an audit.

Access Control: RBAC With Context

Role-based access control is the baseline, but healthcare needs contextual RBAC: a doctor shouldn't see every patient — only patients with whom they have an active care relationship.

javascript
// middleware/authorize.js
async function canAccessPatientRecord(req, res, next) {
const { userId, role } = req.auth; // from verified JWT
const { patientId } = req.params;

if (role === 'patient') {
if (userId !== patientId) {
await audit.log({ actor: userId, action: 'ACCESS_DENIED',
resource: patient:${patientId} });
return res.status(403).json({ error: 'Forbidden' });
}
return next();
}

if (role === 'doctor') {
// Care relationship check — the piece most implementations skip
const relationship = await db.query(
SELECT 1 FROM care_relationships
WHERE doctor_id = $1 AND patient_id = $2
AND status = 'active'
,
[userId, patientId]
);
if (relationship.rowCount === 0) {
await audit.log({ actor: userId, action: 'ACCESS_DENIED',
resource: patient:${patientId} });
return res.status(403).json({ error: 'Forbidden' });
}
return next();
}

return res.status(403).json({ error: 'Forbidden' });
}

Note that denied attempts are logged too — under HIPAA, an access attempt is an auditable event, and denied-access patterns are exactly how you detect a compromised account probing for data.

Encryption: In Transit, At Rest, and In the Column

TLS 1.2+ everywhere is assumed (terminate at the gateway, and use TLS between internal services too — "internal network" is not a security boundary HIPAA recognizes). Disk-level encryption (AWS RDS encryption, for instance) is also assumed. The layer teams miss is column-level encryption for the most sensitive fields, so that even a leaked database dump or a misconfigured read replica doesn't expose diagnoses:

javascript
// crypto/phi.js — AES-256-GCM with per-record IVs
const crypto = require('crypto');
const KEY = Buffer.from(process.env.PHI_ENCRYPTION_KEY, 'hex'); // from KMS, never hardcoded

function encryptPHI(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
ciphertext: enc.toString('base64'),
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
};
}

function decryptPHI({ ciphertext, iv, tag }) {
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY,
Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
}

Two operational rules: the key lives in a KMS (AWS KMS, GCP Cloud KMS, Vault) and is rotated on a schedule; and GCM's auth tag gives you integrity verification for free — if the ciphertext was tampered with, decryption throws instead of returning silently corrupted health data.

The Audit Trail: Append-Only or It Doesn't Count

An audit log that the application can UPDATE or DELETE is not an audit log. Enforce immutability in the database itself:

sql
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
actor_id UUID NOT NULL,
actor_role TEXT NOT NULL,
action TEXT NOT NULL, -- VIEW, CREATE, UPDATE, EXPORT, ACCESS_DENIED
resource TEXT NOT NULL, -- e.g. 'patient:uuid', 'consultation:uuid'
ip_address INET,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- The app role can INSERT and SELECT. Nothing can UPDATE or DELETE.
REVOKE UPDATE, DELETE ON audit_log FROM app_role;

Log every PHI read — not just writes. "Who viewed this record and when" is precisely the question an Office for Civil Rights investigation asks, and it's the question most systems can't answer because they only logged mutations.

Video Consultations: Keep Media Off Your Servers

The video call itself carries PHI (the conversation is health information), but you can architect so the media never touches your infrastructure. Use a WebRTC provider that supports HIPAA workflows and will sign a Business Associate Agreement (BAA) — this is non-negotiable, and it applies to every vendor in your stack that could touch PHI: video, cloud hosting, email, SMS, error tracking, analytics. Your API's job is only session brokering:

javascript
// POST /consultations/:id/video-token
// API issues a short-lived room token; media flows peer-to-provider, not through us
router.post('/consultations/:id/video-token',
canAccessConsultation,
async (req, res) => {
const token = videoProvider.createToken({
room: consult-${req.params.id},
identity: req.auth.userId,
ttl: 900, // 15 minutes — short-lived by design
});
await audit.log({ actor: req.auth.userId, action: 'VIDEO_JOIN',
resource: consultation:${req.params.id} });
res.json({ token });
});

If you enable recording, the recording is PHI at rest: it needs the same encryption, access control, and audit treatment as a medical record — a detail that's easy to miss when the video SDK makes recording a one-line config flag.

The Pitfalls That Actually Cause Breaches

PHI in logs. console.log(req.body) on a symptoms endpoint just wrote diagnoses into your logging pipeline — and your log platform probably hasn't signed a BAA. Scrub PHI fields at the logger level, not by developer discipline.

PHI in URLs. GET /patients?name=John+Smith puts PHI into server access logs, browser history, and proxies. Identifiers go in the path or body; health data never goes in query strings.

Tokens that live too long. A 30-day JWT on a shared family tablet is an unauthorized-access finding waiting to happen. Use short-lived access tokens (~15 minutes) with rotating refresh tokens, and implement server-side revocation.

Error messages that leak. A stack trace returning "column diagnosis_code does not exist" tells an attacker your schema. Sanitize error responses in production; log details server-side only.

Skipping the BAA on "minor" vendors. Your error tracker, your email provider, your push notification service — if PHI can reach them, they need a BAA. This is the compliance gap auditors find first because engineering teams don't think of Sentry as a "healthcare vendor."

Backups nobody tested. Availability is a HIPAA requirement. Encrypted backups you've never restored are a hypothesis, not a disaster recovery plan.

Wrapping Up

HIPAA compliance isn't a feature you add — it's a set of properties your architecture either has or lacks: minimal PHI surface area, contextual access control, encryption with managed keys, append-only auditing, BAAs across the vendor chain, and operational discipline around logs, tokens, and backups. Build these in from the first commit and compliance becomes a natural consequence of the design. Bolt them on later and you'll rebuild the system under the least pleasant deadline there is: a regulator's.

Top comments (0)