Privacy-First State Management in Medical Applications
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
});
}
}));
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();
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>
);
}
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.