Back to Articles

Privacy-First State Management in Medical Applications

Privacy-first state management illustration

Frontend state management in healthcare applications presents unique challenges that don't exist in typical web development. When your Redux store or Zustand slice contains Protected Health Information (PHI), every state update, persistence strategy, and debugging tool becomes a potential compliance risk. This article explores patterns for building state management systems that treat privacy as a first-class architectural concern.

We'll cover four critical areas: secure session handling, automatic PHI cleanup, encrypted client-side storage, and audit trail generation. Each section includes production-ready code you can adapt for your healthcare applications.

The Hidden Risks of Frontend State

Before diving into solutions, let's understand the risks. In a typical healthcare application, patient data flows through multiple state management layers:

  • API Response Caching: React Query or SWR caching patient records in memory
  • Global State: Redux/Zustand stores containing current patient context
  • Form State: React Hook Form or Formik managing intake form data
  • Persistence: localStorage/sessionStorage saving session state
  • DevTools: Redux DevTools exposing full state history

Each of these represents a potential exposure point. A developer with browser DevTools open sees the entire state tree. A shared computer retains localStorage data. An error logging service captures state snapshots. Let's systematically address each risk.

Secure Session State Architecture

The foundation of privacy-first state management is separating sensitive data from general application state. Here's a pattern using Zustand with explicit PHI boundaries:

// stores/createSecureStore.ts
import { create, StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';

// Types for PHI handling
interface PHIData {
  __isPHI: true;
  encryptedValue: string;
  accessedAt: number;
  accessedBy: string;
}

// Middleware to intercept and secure PHI
const secureMiddleware = <T extends object>(
  config: StateCreator<T>
): StateCreator<T> => (set, get, api) => {
  const secureSet: typeof set = (partial, replace) => {
    // Intercept state updates
    const update = typeof partial === 'function' 
      ? partial(get()) 
      : partial;
    
    // Scan for PHI markers and encrypt
    const secured = processStateForPHI(update as object);
    
    return set(secured as T, replace);
  };
  
  return config(secureSet, get, api);
};

// Strip PHI from devtools in production
const createSecureDevtools = <T extends object>() => {
  if (process.env.NODE_ENV === 'production') {
    // No devtools in production - PHI should never be exposed
    return (config: StateCreator<T>) => config;
  }
  
  return devtools<T>((set, get, api) => {
    // In development, redact PHI fields in devtools
    return {
      ...set,
      // Override to mask PHI in devtools state snapshots
    };
  }, {
    name: 'Healthcare App',
    serialize: {
      replacer: (key: string, value: unknown) => {
        if (isPHIField(key) || (value as PHIData)?.__isPHI) {
          return '[PHI REDACTED]';
        }
        return value;
      }
    }
  });
};

// Helper to identify PHI fields
const PHI_FIELD_PATTERNS = [
  /ssn/i,
  /socialSecurity/i,
  /dateOfBirth/i,
  /dob/i,
  /medicalRecord/i,
  /mrn/i,
  /diagnosis/i,
  /treatment/i,
  /insurance/i,
  /address/i,
  /phone/i,
  /email/i
];

function isPHIField(fieldName: string): boolean {
  return PHI_FIELD_PATTERNS.some(pattern => pattern.test(fieldName));
}

Implementing the Secure Patient Store

// stores/patientStore.ts
import { create } from 'zustand';
import { encrypt, decrypt } from '../lib/encryption';

interface PatientState {
  // Non-PHI data (safe to expose)
  currentPatientId: string | null;
  isLoading: boolean;
  error: string | null;
  
  // PHI data (encrypted/protected)
  _encryptedPatientData: string | null;
  _phiAccessLog: PHIAccessEntry[];
  
  // Actions
  loadPatient: (patientId: string, userId: string) => Promise<void>;
  getPatientData: (userId: string) => Patient | null;
  clearPatientData: () => void;
}

interface PHIAccessEntry {
  timestamp: number;
  userId: string;
  action: 'read' | 'write';
  fields: string[];
}

export const usePatientStore = create<PatientState>((set, get) => ({
  currentPatientId: null,
  isLoading: false,
  error: null,
  _encryptedPatientData: null,
  _phiAccessLog: [],
  
  loadPatient: async (patientId, userId) => {
    set({ isLoading: true, error: null });
    
    try {
      const response = await fetch(`/api/patients/${patientId}`, {
        headers: { 'X-User-Id': userId }
      });
      
      if (!response.ok) throw new Error('Failed to load patient');
      
      const patientData = await response.json();
      
      // Encrypt before storing in state
      const encryptedData = await encrypt(
        JSON.stringify(patientData),
        getSessionKey()
      );
      
      // Log PHI access
      const accessEntry: PHIAccessEntry = {
        timestamp: Date.now(),
        userId,
        action: 'read',
        fields: Object.keys(patientData)
      };
      
      set(state => ({
        currentPatientId: patientId,
        isLoading: false,
        _encryptedPatientData: encryptedData,
        _phiAccessLog: [...state._phiAccessLog, accessEntry]
      }));
      
    } catch (error) {
      set({ 
        isLoading: false, 
        error: error instanceof Error ? error.message : 'Unknown error'
      });
    }
  },
  
  getPatientData: (userId) => {
    const { _encryptedPatientData, _phiAccessLog } = get();
    
    if (!_encryptedPatientData) return null;
    
    // Log this access
    set(state => ({
      _phiAccessLog: [...state._phiAccessLog, {
        timestamp: Date.now(),
        userId,
        action: 'read',
        fields: ['*']
      }]
    }));
    
    // Decrypt on demand
    const decrypted = decrypt(_encryptedPatientData, getSessionKey());
    return JSON.parse(decrypted);
  },
  
  clearPatientData: () => {
    set({
      currentPatientId: null,
      _encryptedPatientData: null
      // Keep access log for audit trail
    });
  }
}));
Why Encrypt in Memory?

You might wonder why we encrypt data that's already in JavaScript memory. The answer is defense-in-depth: browser extensions, debugging tools, and error reporters can access JavaScript state. Encryption ensures that even if state is exposed, the actual PHI remains protected. The session key should be derived from the authentication token and never persisted.

Automatic PHI Cleanup

One of the most dangerous PHI exposure risks is stale data. When a user switches patients, logs out, or their session times out, all PHI must be immediately purged. Here's a comprehensive cleanup system:

// lib/phiCleanup.ts
type CleanupCallback = () => void | Promise<void>;

class PHICleanupManager {
  private callbacks: Map<string, CleanupCallback> = new Map();
  private cleanupTimeout: NodeJS.Timeout | null = null;
  private readonly SESSION_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
  
  constructor() {
    // Auto-cleanup on visibility change (user switches tabs)
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.startCleanupTimer();
      } else {
        this.cancelCleanupTimer();
      }
    });
    
    // Cleanup on page unload
    window.addEventListener('beforeunload', () => {
      this.executeCleanup('unload');
    });
    
    // Cleanup on session events
    window.addEventListener('storage', (e) => {
      if (e.key === 'logout' && e.newValue) {
        this.executeCleanup('logout');
      }
    });
  }
  
  register(id: string, callback: CleanupCallback): () => void {
    this.callbacks.set(id, callback);
    
    // Return unregister function
    return () => {
      this.callbacks.delete(id);
    };
  }
  
  private startCleanupTimer(): void {
    this.cleanupTimeout = setTimeout(() => {
      this.executeCleanup('timeout');
    }, this.SESSION_TIMEOUT_MS);
  }
  
  private cancelCleanupTimer(): void {
    if (this.cleanupTimeout) {
      clearTimeout(this.cleanupTimeout);
      this.cleanupTimeout = null;
    }
  }
  
  async executeCleanup(reason: string): Promise<void> {
    console.log(`[PHI Cleanup] Executing cleanup. Reason: ${reason}`);
    
    const cleanupPromises = Array.from(this.callbacks.values())
      .map(callback => {
        try {
          return Promise.resolve(callback());
        } catch (error) {
          console.error('[PHI Cleanup] Callback error:', error);
          return Promise.resolve();
        }
      });
    
    await Promise.all(cleanupPromises);
    
    // Clear all browser storage
    this.clearBrowserStorage();
    
    // Notify other tabs
    localStorage.setItem('logout', Date.now().toString());
    localStorage.removeItem('logout');
  }
  
  private clearBrowserStorage(): void {
    // Clear localStorage (except non-PHI settings)
    const preserveKeys = ['theme', 'language', 'accessibility'];
    const keysToRemove: string[] = [];
    
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key && !preserveKeys.includes(key)) {
        keysToRemove.push(key);
      }
    }
    
    keysToRemove.forEach(key => localStorage.removeItem(key));
    
    // Clear sessionStorage entirely
    sessionStorage.clear();
    
    // Clear IndexedDB
    if ('indexedDB' in window) {
      indexedDB.databases?.().then(databases => {
        databases.forEach(db => {
          if (db.name) indexedDB.deleteDatabase(db.name);
        });
      });
    }
    
    // Clear service worker caches
    if ('caches' in window) {
      caches.keys().then(names => {
        names.forEach(name => caches.delete(name));
      });
    }
  }
}

export const phiCleanup = new PHICleanupManager();

Integrating Cleanup with State Management

// stores/patientStore.ts (enhanced)
import { phiCleanup } from '../lib/phiCleanup';

export const usePatientStore = create<PatientState>((set, get) => {
  // Register cleanup handler on store creation
  phiCleanup.register('patientStore', () => {
    set({
      currentPatientId: null,
      _encryptedPatientData: null,
      _phiAccessLog: []
    });
  });
  
  return {
    // ... store implementation
  };
});

Encrypted Client-Side Storage

Sometimes you need to persist state across page reloads—for example, to preserve form progress during a complex patient intake. Here's a secure storage layer:

// lib/secureStorage.ts
import { encrypt, decrypt, deriveKey } from './encryption';

interface StorageOptions {
  expiresIn?: number; // milliseconds
  requireAuth?: boolean;
}

interface StoredItem<T> {
  data: T;
  metadata: {
    createdAt: number;
    expiresAt: number | null;
    createdBy: string;
    checksum: string;
  };
}

class SecureStorage {
  private keyPromise: Promise<CryptoKey> | null = null;
  
  private async getKey(): Promise<CryptoKey> {
    if (!this.keyPromise) {
      // Derive key from session token
      const sessionToken = this.getSessionToken();
      if (!sessionToken) {
        throw new Error('No active session');
      }
      
      this.keyPromise = deriveKey(sessionToken);
    }
    
    return this.keyPromise;
  }
  
  private getSessionToken(): string | null {
    // Get from your auth system
    return sessionStorage.getItem('auth_token');
  }
  
  async setItem<T>(
    key: string, 
    value: T, 
    options: StorageOptions = {}
  ): Promise<void> {
    const cryptoKey = await this.getKey();
    
    const item: StoredItem<T> = {
      data: value,
      metadata: {
        createdAt: Date.now(),
        expiresAt: options.expiresIn 
          ? Date.now() + options.expiresIn 
          : null,
        createdBy: this.getCurrentUserId(),
        checksum: await this.generateChecksum(value)
      }
    };
    
    const encrypted = await encrypt(
      JSON.stringify(item),
      cryptoKey
    );
    
    // Prefix key to identify encrypted items
    localStorage.setItem(`__encrypted_${key}`, encrypted);
    
    // Log PHI storage event
    this.logStorageAccess(key, 'write');
  }
  
  async getItem<T>(key: string): Promise<T | null> {
    const encrypted = localStorage.getItem(`__encrypted_${key}`);
    
    if (!encrypted) return null;
    
    try {
      const cryptoKey = await this.getKey();
      const decrypted = await decrypt(encrypted, cryptoKey);
      const item: StoredItem<T> = JSON.parse(decrypted);
      
      // Check expiration
      if (item.metadata.expiresAt && Date.now() > item.metadata.expiresAt) {
        await this.removeItem(key);
        return null;
      }
      
      // Verify checksum
      const currentChecksum = await this.generateChecksum(item.data);
      if (currentChecksum !== item.metadata.checksum) {
        console.error('Storage integrity check failed');
        await this.removeItem(key);
        return null;
      }
      
      // Log PHI access
      this.logStorageAccess(key, 'read');
      
      return item.data;
    } catch (error) {
      // Decryption failed - wrong key or corrupted data
      console.error('Failed to decrypt stored item:', error);
      await this.removeItem(key);
      return null;
    }
  }
  
  async removeItem(key: string): Promise<void> {
    localStorage.removeItem(`__encrypted_${key}`);
    this.logStorageAccess(key, 'delete');
  }
  
  private async generateChecksum(data: unknown): Promise<string> {
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(JSON.stringify(data));
    const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  }
  
  private getCurrentUserId(): string {
    // Get from your auth system
    return sessionStorage.getItem('user_id') || 'unknown';
  }
  
  private logStorageAccess(key: string, action: string): void {
    // Send to audit logging service
    const logEntry = {
      timestamp: new Date().toISOString(),
      userId: this.getCurrentUserId(),
      action: `storage_${action}`,
      resource: key,
      userAgent: navigator.userAgent
    };
    
    // Queue for batch sending
    navigator.sendBeacon?.('/api/audit-log', JSON.stringify(logEntry));
  }
  
  // Clear encryption key on logout
  clearKey(): void {
    this.keyPromise = null;
  }
}

export const secureStorage = new SecureStorage();
Browser Crypto API Limitations

The Web Crypto API provides strong encryption, but remember: if the browser is compromised, so is the key. This encryption protects against casual exposure (someone glancing at DevTools) and provides defense-in-depth, but it's not a substitute for server-side security. Never store highly sensitive data client-side if it can be avoided.

Audit Trail Generation

HIPAA requires detailed audit trails of all PHI access. While the server maintains the authoritative audit log, the frontend can provide valuable context about how data was accessed:

// lib/auditTrail.ts
interface AuditEvent {
  id: string;
  timestamp: string;
  userId: string;
  sessionId: string;
  action: AuditAction;
  resource: {
    type: 'patient' | 'record' | 'document' | 'form';
    id: string;
    fields?: string[];
  };
  context: {
    component: string;
    route: string;
    userAgent: string;
    viewport: { width: number; height: number };
  };
  outcome: 'success' | 'failure' | 'partial';
  metadata?: Record<string, unknown>;
}

type AuditAction = 
  | 'view'
  | 'search'
  | 'export'
  | 'print'
  | 'copy'
  | 'modify'
  | 'delete'
  | 'share';

class AuditTrailService {
  private queue: AuditEvent[] = [];
  private flushInterval: NodeJS.Timeout;
  private readonly FLUSH_INTERVAL_MS = 5000;
  private readonly MAX_QUEUE_SIZE = 50;
  
  constructor() {
    // Periodic flush
    this.flushInterval = setInterval(() => {
      this.flush();
    }, this.FLUSH_INTERVAL_MS);
    
    // Flush on page unload
    window.addEventListener('beforeunload', () => {
      this.flush();
    });
    
    // Detect copy events on PHI elements
    document.addEventListener('copy', (e) => {
      const selection = window.getSelection()?.toString();
      if (selection && this.mightContainPHI(selection)) {
        this.log({
          action: 'copy',
          resource: {
            type: 'record',
            id: 'unknown',
            fields: ['clipboard_content']
          },
          metadata: {
            contentLength: selection.length,
            contentHash: this.hashContent(selection)
          }
        });
      }
    });
    
    // Detect print events
    window.addEventListener('beforeprint', () => {
      this.log({
        action: 'print',
        resource: {
          type: 'document',
          id: window.location.pathname
        }
      });
    });
  }
  
  log(event: Partial<AuditEvent>): void {
    const fullEvent: AuditEvent = {
      id: crypto.randomUUID(),
      timestamp: new Date().toISOString(),
      userId: this.getCurrentUserId(),
      sessionId: this.getSessionId(),
      action: event.action || 'view',
      resource: event.resource || { type: 'record', id: 'unknown' },
      context: {
        component: event.context?.component || 'unknown',
        route: window.location.pathname,
        userAgent: navigator.userAgent,
        viewport: {
          width: window.innerWidth,
          height: window.innerHeight
        }
      },
      outcome: event.outcome || 'success',
      metadata: event.metadata
    };
    
    this.queue.push(fullEvent);
    
    if (this.queue.length >= this.MAX_QUEUE_SIZE) {
      this.flush();
    }
  }
  
  private async flush(): Promise<void> {
    if (this.queue.length === 0) return;
    
    const events = [...this.queue];
    this.queue = [];
    
    try {
      // Use sendBeacon for reliability
      const success = navigator.sendBeacon(
        '/api/audit-log',
        JSON.stringify({ events })
      );
      
      if (!success) {
        // Fallback to fetch
        await fetch('/api/audit-log', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ events }),
          keepalive: true
        });
      }
    } catch (error) {
      // Re-queue failed events
      this.queue = [...events, ...this.queue];
      console.error('Failed to send audit events:', error);
    }
  }
  
  private mightContainPHI(text: string): boolean {
    const phiPatterns = [
      /\d{3}-\d{2}-\d{4}/,  // SSN
      /\d{1,2}\/\d{1,2}\/\d{2,4}/, // Dates
      /MRN[\s:]*\d+/i,  // Medical record numbers
    ];
    
    return phiPatterns.some(pattern => pattern.test(text));
  }
  
  private hashContent(content: string): string {
    // Simple hash for audit purposes (not security)
    let hash = 0;
    for (let i = 0; i < content.length; i++) {
      const char = content.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return hash.toString(16);
  }
  
  private getCurrentUserId(): string {
    return sessionStorage.getItem('user_id') || 'anonymous';
  }
  
  private getSessionId(): string {
    let sessionId = sessionStorage.getItem('audit_session_id');
    if (!sessionId) {
      sessionId = crypto.randomUUID();
      sessionStorage.setItem('audit_session_id', sessionId);
    }
    return sessionId;
  }
}

export const auditTrail = new AuditTrailService();

React Hook for Component-Level Auditing

// hooks/useAuditedData.ts
import { useEffect, useRef } from 'react';
import { auditTrail } from '../lib/auditTrail';

interface UseAuditedDataOptions {
  resourceType: 'patient' | 'record' | 'document' | 'form';
  resourceId: string;
  component: string;
  fields?: string[];
}

export function useAuditedData<T>(
  data: T | null,
  options: UseAuditedDataOptions
): T | null {
  const hasLoggedView = useRef(false);
  const previousData = useRef<T | null>(null);
  
  useEffect(() => {
    // Log initial view
    if (data && !hasLoggedView.current) {
      auditTrail.log({
        action: 'view',
        resource: {
          type: options.resourceType,
          id: options.resourceId,
          fields: options.fields
        },
        context: { component: options.component }
      });
      hasLoggedView.current = true;
    }
    
    // Log data modifications
    if (previousData.current && data) {
      const changes = detectChanges(previousData.current, data);
      if (changes.length > 0) {
        auditTrail.log({
          action: 'modify',
          resource: {
            type: options.resourceType,
            id: options.resourceId,
            fields: changes
          },
          context: { component: options.component }
        });
      }
    }
    
    previousData.current = data;
  }, [data, options]);
  
  // Reset on unmount
  useEffect(() => {
    return () => {
      hasLoggedView.current = false;
    };
  }, [options.resourceId]);
  
  return data;
}

function detectChanges(prev: unknown, current: unknown): string[] {
  // Compare objects and return changed field names
  const changes: string[] = [];
  
  if (typeof prev === 'object' && typeof current === 'object') {
    const prevObj = prev as Record<string, unknown>;
    const currObj = current as Record<string, unknown>;
    
    for (const key of Object.keys(currObj)) {
      if (JSON.stringify(prevObj[key]) !== JSON.stringify(currObj[key])) {
        changes.push(key);
      }
    }
  }
  
  return changes;
}

Putting It All Together

Here's how these patterns work together in a real patient record view component:

// components/PatientRecord.tsx
import { useEffect } from 'react';
import { usePatientStore } from '../stores/patientStore';
import { useAuditedData } from '../hooks/useAuditedData';
import { phiCleanup } from '../lib/phiCleanup';

export function PatientRecord({ patientId }: { patientId: string }) {
  const { loadPatient, getPatientData, clearPatientData } = usePatientStore();
  const userId = useAuth().userId;
  
  // Load patient data on mount
  useEffect(() => {
    loadPatient(patientId, userId);
    
    // Register cleanup for this component
    return phiCleanup.register(`patient-${patientId}`, () => {
      clearPatientData();
    });
  }, [patientId, userId]);
  
  // Get decrypted data with audit logging
  const rawPatient = getPatientData(userId);
  const patient = useAuditedData(rawPatient, {
    resourceType: 'patient',
    resourceId: patientId,
    component: 'PatientRecord',
    fields: ['demographics', 'conditions', 'medications']
  });
  
  if (!patient) {
    return <LoadingSpinner />;
  }
  
  return (
    <div className="patient-record">
      {/* Patient data rendered here */}
    </div>
  );
}
Key Takeaways

Privacy-first state management requires thinking about data protection at every layer: how data enters state, how it's stored, how it's accessed, and how it's cleaned up. By building these patterns into your architecture from the start, you create a foundation that makes compliance the default, not an afterthought.

Next Steps

These patterns provide a solid foundation, but every healthcare application has unique requirements. Consider how these concepts apply to your specific context: multi-tenant architectures, offline-first applications, or real-time collaboration features.

For more healthcare development patterns, follow DHUX for upcoming articles on FHIR integration, secure file handling, and healthcare-specific performance optimization.