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:
// src/lib/encryption.ts
export function encryptPII(value: string): string {
  const iv = randomBytes(16);
  const cipher = createCipheriv(ALGORITHM, KEY, iv);
  // ...
  return `enc:v1:${iv}:${ciphertext}`; // Prefix identifies encrypted values
}
During form submission, only bank-type fields are encrypted — other fields pass through unchanged:
// Before saving
const encrypted = encryptSubmissionPII(data, schema);
// → { name: "Jane Doe", bank_field: "enc:v1:a3b7..." }
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
pnpm --dir apps/terra test -- -t "Encryption Module"
  • 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:
-- Bank fields should show enc:v1:... not plaintext
SELECT id, data FROM submissions
WHERE data::text LIKE '%accountNumber%'
ORDER BY created_at DESC LIMIT 5;

Submission Data Isolation

// src/lib/security/submission-guard.ts
export async function verifySubmissionAccess(submissionId: string) {
  const submission = await fetchSubmission(submissionId);

  if (!submission.form.is_anonymous) {
    // Authenticated form: verify ownership
    if (submission.user_id !== session.user.id) {
      return { success: false, error: "Unauthorized access" };
    }
  }
  // Anonymous forms: no check (intentional)
}
Tests: 11 cases in plaid-security.test.ts, 16 in status.test.ts
pnpm --dir apps/terra test -- -t "Plaid Security"
pnpm --dir apps/terra test -- -t "Status Lookup Security"
  • 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:
// src/lib/audit-logger.ts
await auditLog({
  action_type: "update",
  entity_type: "submission",
  entity_id: submissionId,
  user_id: session.user.id,
  changes: {
    status: { before: "submitted", after: "approved" },
  },
});
What’s loggedWhat’s NOT logged
User IDs, entity IDs, action typesEmail addresses
Before/after values for changesPhone numbers
Anonymized IP (192.168.1.0)Raw submission data
Timestamps, request IDsSSNs, bank account numbers
Tests: 29 cases in src/lib/__tests__/audit.test.ts
pnpm --dir apps/terra test -- -t "Audit Module"
  • 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:
// src/lib/logger.ts — automatic redaction
logger.info("User action", { email: "jane@example.com" });
// Output: { email: "j***e@example.com" }

logger.info("SMS sent", { phone: "555-123-4567" });
// Output: { phone: "***4567" }
Data typeRedacted output
Emailj***e@example.com
Phone***4567
User IDabc123de***
API keysk_live_***
console.log is banned by ESLint — the linter catches it:
pnpm --dir apps/terra lint