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: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
What the tests prove
What the tests prove
- 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/routingNumberencrypted,bankNamestays plain -confirmAccountNumberstripped from stored data - Submission-level: only bank fields encrypted, everything else untouched - Malformed ciphertext throws a clear error (not garbage data)
submissions table directly:
Submission Data Isolation
plaid-security.test.ts, 16 in status.test.ts
What the tests prove
What the tests prove
- 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:| What’s logged | What’s NOT logged |
|---|---|
| User IDs, entity IDs, action types | Email addresses |
| Before/after values for changes | Phone numbers |
Anonymized IP (192.168.1.0) | Raw submission data |
| Timestamps, request IDs | SSNs, bank account numbers |
src/lib/__tests__/audit.test.ts
What the tests prove
What the tests prove
- 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: falseand 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
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:| Data type | Redacted output |
|---|---|
j***e@example.com | |
| Phone | ***4567 |
| User ID | abc123de*** |
| API key | sk_live_*** |
console.log is banned by ESLint — the linter catches it: