Client-Side Encryption for Healthcare Apps

Published: (December 7, 2025 at 03:11 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

Introduction

I’ve had my health data used against me in court—actual lawyers, an actual judge, and my own pain‑flare journal entries reframed as evidence of instability. That experience drives every security decision below: 150 000 PBKDF2 iterations, AES‑256‑GCM, and a key that never leaves the device. This isn’t a tutorial; it’s the architecture that keeps health data out of discovery motions, custody disputes, and insurance‑fraud investigations.

Threat Model

  • Traditional model: User → Server → Database.

    • Server decrypts to process.
    • Data traverses corporate infrastructure, is accessible to employees, and can be subpoenaed.
    • Breaches, business‑model monetisation, and legal requests expose the data.
  • Zero‑knowledge / local‑first model:

    • The server only ever sees ciphertext (or nothing at all).
    • Data never leaves the user’s device, protecting against theft, malware, shared computers, and forensic analysis.
    • Even if hardware is seized, the attacker gets only noise.

Using the Web Crypto API

The Web Crypto API is built into modern browsers, hardware‑accelerated, and has no external supply‑chain dependencies.

const cryptoAPI = globalThis.crypto ?? window.crypto;
const subtle = cryptoAPI.subtle;

Generating Keys

// AES‑GCM encryption key (256‑bit)
async function generateEncryptionKey(): Promise {
  return subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
}

// HMAC‑SHA‑256 key for integrity
async function generateHMACKey(): Promise {
  return subtle.generateKey(
    { name: 'HMAC', hash: 'SHA-256' },
    true,
    ['sign', 'verify']
  );
}

Encryption

interface EncryptedPayload {
  ciphertext: string;
  iv: string;
  hmac: string;
  algorithm: string;
  version: string;
  timestamp: string;
}

/**
 * Encrypts arbitrary data with AES‑GCM and signs the ciphertext with HMAC.
 */
async function encrypt(
  data: T,
  encryptionKey: CryptoKey,
  hmacKey: CryptoKey
): Promise {
  const plaintext = JSON.stringify(data);
  const plaintextBytes = new TextEncoder().encode(plaintext);

  // Fresh IV for every encryption
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const ciphertextBuffer = await subtle.encrypt(
    { name: 'AES-GCM', iv },
    encryptionKey,
    plaintextBytes
  );

  const hmacSignature = await subtle.sign('HMAC', hmacKey, ciphertextBuffer);

  return {
    ciphertext: arrayBufferToBase64(ciphertextBuffer),
    iv: arrayBufferToBase64(iv.buffer),
    hmac: arrayBufferToBase64(hmacSignature),
    algorithm: 'AES-256-GCM',
    version: '2.0.0',
    timestamp: new Date().toISOString(),
  };
}

Why HMAC?

Even though AES‑GCM provides authentication, an additional HMAC layer offers “belt‑and‑suspenders” protection. Verification occurs before decryption, allowing fast rejection of tampered payloads.

Decryption

/**
 * Decrypts a payload after verifying its HMAC.
 */
async function decrypt(
  payload: EncryptedPayload,
  encryptionKey: CryptoKey,
  hmacKey: CryptoKey
): Promise {
  const ciphertextBuffer = base64ToArrayBuffer(payload.ciphertext);
  const iv = base64ToArrayBuffer(payload.iv);
  const expectedHmac = base64ToArrayBuffer(payload.hmac);

  const isValid = await subtle.verify(
    'HMAC',
    hmacKey,
    expectedHmac,
    ciphertextBuffer
  );

  if (!isValid) {
    throw new Error('Integrity check failed');
  }

  const plaintextBuffer = await subtle.decrypt(
    { name: 'AES-GCM', iv },
    encryptionKey,
    ciphertextBuffer
  );

  return JSON.parse(new TextDecoder().decode(plaintextBuffer));
}

Storage Considerations

OptionDrawbacks
localStorageVulnerable to XSS
sessionStorageLost on tab close
IndexedDBStill XSS‑prone
Password‑derived key (user types each time)Poor UX
extractable: false (non‑exportable keys)Cannot back up or migrate

Layered Keys

  • Master key wraps per‑entry data keys.
  • Password‑derived key wraps backups, enabling secure migration.
/**
 * Wraps a CryptoKey for storage (e.g., in IndexedDB).
 */
async function wrapKeyForStorage(
  keyToWrap: CryptoKey,
  wrappingKey: CryptoKey
): Promise {
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const wrapped = await subtle.wrapKey(
    'raw',
    keyToWrap,
    wrappingKey,
    { name: 'AES-GCM', iv }
  );

  return JSON.stringify({
    wrapped: arrayBufferToBase64(wrapped),
    iv: arrayBufferToBase64(iv.buffer),
  });
}

Password‑Derived Keys

/**
 * Derives an AES‑GCM key from a password using PBKDF2.
 * Default: 150 000 iterations (hard‑coded for legal‑risk scenarios).
 */
async function deriveKeyFromPassword(
  password: string,
  salt: Uint8Array,
  iterations: number = 150_000
): Promise {
  const passwordBuffer = new TextEncoder().encode(password);

  const baseKey = await subtle.importKey(
    'raw',
    passwordBuffer,
    'PBKDF2',
    false,
    ['deriveBits', 'deriveKey']
  );

  return subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations,
      hash: 'SHA-256',
    },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
}
  • Salt is stored alongside the backup (not secret).
  • Iteration count is also stored, allowing future increases without breaking existing backups.

Key Rotation

Regular rotation isn’t just a best practice; it’s a response to device loss, compromise, or legal pressure.

async function rotateKey(keyId: string): Promise {
  const newBundle = await generateKeyBundle();               // new enc + HMAC keys
  const allEntries = await loadAllEncryptedEntries();       // fetch all stored payloads
  const oldKey = await getKey(keyId);                        // retrieve old bundle

  for (const entry of allEntries) {
    const plaintext = await decrypt(entry.data, oldKey.enc, oldKey.hmac);
    const newCiphertext = await encrypt(plaintext, newBundle.encryptionKey, newBundle.hmacKey);
    await updateEntry(entry.id, newCiphertext);
  }

  await archiveKey(keyId, oldKey);   // keep for recovery if needed
  await storeKey(keyId, newBundle);
  await logSecurityEvent({
    type: 'key_rotation',
    keyId,
    timestamp: new Date(),
  });
}

Auditing

All cryptographic operations are logged locally; nothing is sent to a server.

interface SecurityEvent {
  type: string;
  keyId?: string;
  timestamp: Date;
  [prop: string]: any;
}

/**
 * Stores a bounded audit log in localStorage.
 */
function logSecurityEvent(event: SecurityEvent): void {
  const auditLog = JSON.parse(localStorage.getItem('security_audit') ?? '[]');
  auditLog.push({ ...event, timestamp: event.timestamp.toISOString() });

  // Keep the most recent 1 000 entries
  if (auditLog.length > 1000) {
    auditLog.splice(0, auditLog.length - 1000);
  }

  localStorage.setItem('security_audit', JSON.stringify(auditLog));
}

These logs can be presented to users or auditors without involving any backend.

Implementation Notes

  • Private‑browsing: localStorage may be unavailable; fall back to in‑memory storage and warn the user that data won’t persist.
  • Testing: Web Crypto behaves differently under Vitest; mock the API or use @peculiar/webcrypto.
  • Base64 handling: Node.js and browsers differ. Use a helper that works in both environments.
function arrayBufferToBase64(buffer: ArrayBuffer): string {
  if (typeof Buffer !== 'undefined') {
    // Node.js environment
    return Buffer.from(buffer).toString('base64');
  }
  // Browser environment
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

function base64ToArrayBuffer(base64: string): ArrayBuffer {
  if (typeof Buffer !== 'undefined') {
    // Node.js
    return Buffer.from(base64, 'base64').buffer;
  }
  const binary = atob(base64);
  const len = binary.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

Conclusion

By keeping encryption entirely on the client, using strong primitives (AES‑256‑GCM, HMAC‑SHA‑256, PBKDF2 with 150 k iterations), and logging everything locally, you can protect health data from subpoenas, breaches, and any party that might weaponise a patient’s personal narrative. This architecture is built for people whose data could be used against them—disability claimants, chronic‑pain patients, and anyone the system has already decided to disbelieve.

Back to Blog

Related posts

Read more »