Skip to main content

Data Protection

Sensitive data is encrypted at rest, isolated per user, and never appears in logs.

PII Encryption

Bank account numbers and SSNs are encrypted with AES before storage. Each value gets a random IV — the same plaintext produces different ciphertext every time:
During form submission, only bank-type fields are encrypted — other fields pass through unchanged:
Confirmation fields (email_confirm, confirmAccountNumber) are stripped entirely — they exist only for UI validation and are never stored. Tests: 32 cases in src/lib/__tests__/encryption.test.ts
  • Round-trip encryption/decryption works for strings, special chars, and unicode - Random IV produces different ciphertext for same plaintext - Plain values pass through decryption safely (no double-encrypt risk) - Bank data: accountNumber/routingNumber encrypted, bankName stays plain - confirmAccountNumber stripped from stored data - Submission-level: only bank fields encrypted, everything else untouched - Malformed ciphertext throws a clear error (not garbage data)
Try to break it: After submitting a form with bank fields, query the submissions table directly:

Submission Data Isolation

Tests: 11 cases in plaid-security.test.ts, 16 in status.test.ts
  • Owner can access their own submission data - Cross-user access blocked (User A cannot see User B’s bank details) - Unauthenticated users blocked on authenticated forms - Anonymous forms work without session (by design) - Status lookup returns only status + form name — no PII fields - Only 8-char reference ID exposed, not full UUID

Audit Logging

Every significant action creates an immutable audit record for SOC 2 compliance:
Tests: 29 cases in src/lib/__tests__/audit.test.ts
  • IP anonymized: last octet zeroed for IPv4, prefix-only for IPv6 - Missing session logs null user fields (no crash) - Before/after changes stored correctly - Failed actions logged with success: false and error message - All action types covered: form CRUD, publish, team invite/remove, status change, external sync - Non-blocking: audit failures don’t block the original action
Try to break it: Perform a sequence of actions (create form → publish → invite member → submit → change status) and query audit_logs. Every action should have an entry. Search for email patterns — there should be none.

Log Safety

All logging goes through a structured logger with PII redaction:
console.log is banned by ESLint — the linter catches it:

Functional Logic

Core workflows and integrations

Known Gaps

What’s missing and the plan