GDPR Art. 32 requires technical measures to ensure security appropriate to the risk, and storing personal data in plaintext is explicitly the risk the article addresses. OWASP A02 (Cryptographic Failures) lists cleartext storage of sensitive data as a top-two web vulnerability class. A single SQL injection, misconfigured read replica, or compromised database backup immediately yields every email address, phone number, SSN, and address in your system. ISO-27001:2022 A.8.24 requires cryptographic controls to protect data confidentiality — a plaintext PII column fails that control directly. CWE-311 and CWE-312 both describe this failure as enabling credential exposure and privacy violations at scale.
Critical because a single database read — via injection, misconfigured backup, or insider access — exposes every user's plaintext PII with no additional barrier.
Implement AES-256-GCM field-level encryption in lib/encryption.ts and apply it via an ORM lifecycle hook so every write encrypts and every read decrypts automatically.
// lib/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
const ALGORITHM = 'aes-256-gcm'
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex') // 32-byte key from secrets manager
export function encrypt(plaintext: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, KEY, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
}
export function decrypt(ciphertext: string): string {
const [ivHex, tagHex, dataHex] = ciphertext.split(':')
const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(ivHex, 'hex'))
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
return decipher.update(Buffer.from(dataHex, 'hex')) + decipher.final('utf8')
}
Generate the key with openssl rand -hex 32 and store it in your secrets manager (AWS Secrets Manager, Vercel environment variables, or similar) — never inline in code. Verify that cloud database at-rest encryption is enabled at the infrastructure level (Supabase and AWS RDS both default to encrypted volumes; confirm in your project settings).
ID: data-protection.data-collection-consent.encryption-at-rest
Severity: critical
What to look for: Enumerate every relevant item. Check database schema for how sensitive columns are typed and stored. In Prisma, look for @db.Bytes or custom field types on email, phone, SSN, address, and payment fields. Look for application-level encryption helpers (search for encrypt, AES, cipher, crypto.createCipheriv). Check ORM lifecycle hooks (Prisma extensions, Mongoose plugins, TypeORM subscribers) for automatic encryption/decryption. Verify that encryption keys are loaded from environment variables or a secrets manager — not hardcoded in the encryption utility. Check for key rotation mechanisms (versioned keys). Also check cloud database settings: if using Supabase, AWS RDS, or PlanetScale, confirm that at-rest encryption is enabled at the infrastructure level (this is often the platform default but worth confirming).
Pass criteria: At least 1 of the following conditions is met. Sensitive personal data (email, phone, SSN, payment details, addresses, biometrics) is encrypted before storage using AES-256-GCM or equivalent. Encryption keys are stored separately from the application code (environment variable loaded from a secrets manager, Vercel KV, AWS Secrets Manager, or similar). The database contains ciphertext, not plaintext PII, for sensitive columns. Before evaluating, extract and quote the relevant configuration or code patterns found. Report the count of items checked even on pass.
Fail criteria: Personal data is stored in plaintext in the database. Encryption is applied but the key is hardcoded in the source file. Only certain fields are encrypted while other PII (e.g., phone, address) remains plaintext.
Do NOT pass when: The item exists only as a placeholder, stub, or TODO comment — partial implementation does not count as passing.
Skip (N/A) when: The application collects no personal data and no user identifiers beyond a session token.
Cross-reference: For deployment and infrastructure concerns, the Deployment Readiness audit covers production configuration.
Detail on fail: Specify which data types are unencrypted. Example: "Email addresses and phone numbers stored in plaintext in users table. No encryption-at-rest logic found in codebase." or "Encryption helper found in lib/crypto.ts but encryption key hardcoded in same file.".
Remediation: Implement field-level encryption in the application layer:
// lib/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
const ALGORITHM = 'aes-256-gcm'
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex') // 32-byte key from secrets manager
export function encrypt(plaintext: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, KEY, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// Store: iv (hex) + ':' + tag (hex) + ':' + ciphertext (hex)
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
}
export function decrypt(ciphertext: string): string {
const [ivHex, tagHex, dataHex] = ciphertext.split(':')
const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(ivHex, 'hex'))
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
return decipher.update(Buffer.from(dataHex, 'hex')) + decipher.final('utf8')
}
Apply via a Prisma extension or Mongoose plugin to encrypt on write and decrypt on read automatically. Generate the key with openssl rand -hex 32 and store it in your secrets manager — never in code.