How to store scan results, stay compliant with health data regulations, and protect user privacy at every stage.
HIPAA applies to covered entities (hospitals, doctors, health plans, insurers) and their business associates (anyone who handles PHI on their behalf). A standalone consumer wellness app that doesn't integrate with healthcare providers is technically NOT a covered entity.
Phone (Client-Side Only) ┌─────────────────────────────────────────────┐ │ │ │ Camera/Sensors │ │ │ │ │ ▼ │ │ MediaPipe / ONNX / Web Audio │ │ │ │ │ ▼ │ │ 346 Marker Results (processed locally) │ │ │ │ │ ├──▶ IndexedDB (encrypted) │ │ │ └─ AES-256 with user passphrase │ │ │ │ │ ├──▶ Export to PDF (user-initiated) │ │ │ │ │ └──▶ Export to JSON (for backup) │ │ │ │ YOUR SERVERS NEVER SEE THIS DATA │ └─────────────────────────────────────────────┘
| Storage | Method | Capacity | Encryption |
|---|---|---|---|
| Scan Results | IndexedDB | Unlimited (browser-managed) | Web Crypto API — AES-256-GCM with user-derived key |
| User Profile | IndexedDB | Small (<10KB) | Same encryption key |
| Scan History | IndexedDB | ~34KB per scan × N scans | Same encryption key |
| Settings | localStorage | <1KB | Not needed (no PHI) |
// Derive encryption key from user's passphrase async function deriveKey(passphrase: string): Promise<CryptoKey> { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: enc.encode('vhscanner-salt'), iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } // Encrypt scan results before storing async function encryptResults(data: object, key: CryptoKey): Promise<{iv: Uint8Array, data: ArrayBuffer}> { const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(data)) ); return { iv, data: encrypted }; } // Store in IndexedDB async function saveScan(scanResults: MarkerResult[], passphrase: string) { const key = await deriveKey(passphrase); const { iv, data } = await encryptResults(scanResults, key); // Store iv + data in IndexedDB — even if someone accesses the DB, they can't read it }
Phone (Client-Side) ┌─────────────────────────────────────────┐ │ Camera → Processing → 346 Markers │ │ │ │ │ ▼ │ │ ENCRYPT with user's key BEFORE sending │ └───────┬─────────────────────────────────┘ │ HTTPS (TLS 1.3) ▼ Supabase (Pro Plan + BAA signed) ┌─────────────────────────────────────────┐ │ │ │ Auth: Supabase Auth (email/OAuth) │ │ RLS: user can ONLY read own rows │ │ │ │ Table: scans │ │ - id (uuid, PK) │ │ - user_id (FK → auth.users) │ │ - scan_date (timestamptz) │ │ - scan_types (text[]) │ │ - encrypted_results (bytea) ← AES │ │ - iv (bytea) │ │ - result_hash (text) ← integrity │ │ - created_at (timestamptz) │ │ │ │ Table: scan_access_log (audit trail) │ │ - id, user_id, action, ip, │ │ user_agent, accessed_at │ │ │ │ NO raw images │ │ NO video frames │ │ NO PII in marker values │ │ │ │ Encryption at rest: AES-256 (default) │ │ Backups: encrypted + geo-redundant │ └─────────────────────────────────────────┘
-- Scan results table CREATE TABLE scans ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, scan_date TIMESTAMPTZ NOT NULL DEFAULT now(), scan_types TEXT[] NOT NULL, encrypted_results BYTEA NOT NULL, iv BYTEA NOT NULL, result_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- RLS: users can only see their own scans ALTER TABLE scans ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users read own scans" ON scans FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users insert own scans" ON scans FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users delete own scans" ON scans FOR DELETE USING (auth.uid() = user_id); -- Audit log (append-only, no delete) CREATE TABLE scan_access_log ( id BIGSERIAL PRIMARY KEY, user_id UUID REFERENCES auth.users(id), action TEXT NOT NULL, scan_id UUID REFERENCES scans(id), ip_address INET, user_agent TEXT, accessed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Index for fast user lookups CREATE INDEX idx_scans_user ON scans(user_id, scan_date DESC); CREATE INDEX idx_audit_user ON scan_access_log(user_id, accessed_at DESC);
| Rule | What It Means | How to Implement | Cost |
|---|---|---|---|
| Encryption at Rest | PHI must be encrypted when stored on any disk, database, or backup | Supabase Pro encrypts at rest by default (AES-256). Add app-level encryption on marker values before storing. | $0 (built-in) |
| Encryption in Transit | PHI must be encrypted during transfer between client and server | HTTPS only (TLS 1.2+). Never allow HTTP fallback. Supabase enforces HTTPS. | $0 (default) |
| Access Controls | Only authorized users can see health data. Minimum necessary access. | Supabase RLS + Auth. Each user can only read their own rows. No global admin data view without audit. | $0 (built-in) |
| Audit Trail | Log every access to PHI: who, when, what, from where. Keep 6 years. | scan_access_log table. Log on every read/write. 6-year retention policy. |
~$5/mo storage |
| BAA | Written agreement with every vendor that touches PHI | Supabase Pro: signs BAA. Vercel Enterprise: signs BAA. Stripe: has BAA for billing if tied to health services. | $25/mo (Supabase Pro) |
| Minimum Necessary | Only collect and store what's needed. Don't over-collect. | Store marker values only. No raw video, no camera frames, no audio recordings. Delete raw data immediately after processing. | $0 (architecture decision) |
| Breach Notification | Notify affected users within 60 days of discovering a breach. Notify HHS if >500 people affected. | Incident response plan document. Email notification system. Breach assessment template. | $0 (document) |
| Data Disposal | Delete PHI when no longer needed. Users must be able to delete their data. | "Delete my data" button. Cascading delete on user account deletion. Auto-purge policy (e.g., 3 years inactive). | $0 (code) |
| Risk Assessment | Annual assessment of risks to PHI. Document threats and mitigations. | Annual review document. Can be simple spreadsheet for a startup. Update when architecture changes. | $0 (document) |
| Vendor | BAA Available? | Plan Required | Monthly Cost | Notes |
|---|---|---|---|---|
| Supabase | ✓ Yes | Pro ($25/mo) | $25+ | Postgres encrypted at rest, RLS built-in, SOC 2 Type II |
| Vercel | ✓ Enterprise only | Enterprise (custom) | $$$ | For Option A (client-side), Vercel just serves static files — no PHI touches Vercel, so no BAA needed |
| Cloudflare | ✓ Yes | Enterprise | $$$ | CDN/DNS only for Option A — no BAA needed if no PHI in transit headers |
| Stripe | ✓ Yes | Standard (free) | $0 | Only needed if billing is tied to health services |
| GitHub | ✗ No | N/A | N/A | Never push PHI to repos. Code only, no health data. |
| Data Type | Process? | Store? | Why |
|---|---|---|---|
| Raw camera video frames | ✓ In memory | ✗ Never | Facial biometric data under BIPA/GDPR. Process → extract markers → discard frames immediately. |
| Audio recordings (voice/breath) | ✓ In memory | ✗ Never | Voice is biometric. Extract features → discard audio. |
| Tongue/nail photos | ✓ In memory | ✗ Never | Medical images. Process → extract color/texture values → discard photos. |
| Face mesh coordinates | ✓ In memory | ✗ Never | Biometric identifier. Use for rPPG ROI extraction only → discard. |
| Marker values (numbers) | ✓ | ✓ Encrypted | This is what you store — just the 346 numeric results. No images, no video, no audio. |
| Scan metadata (date, types) | ✓ | ✓ OK | "User did a face scan on April 13" is low-risk metadata. |
| User profile (age, sex, height) | ✓ | ✓ Encrypted | Needed for age/sex-normalized reference ranges. |
scans table with encrypted_results (bytea) columnscan_access_log audit table (append-only)