Issue: #651
Status: Implemented
Area: Security | Priority: P1-high
Sensitive database fields are encrypted at rest using AES-256-GCM (authenticated encryption). Encryption and decryption happen transparently in the service layer — callers work with plaintext values.
| Table | Column | Classification | Storage |
|---|---|---|---|
users |
email |
PII | AES-256-GCM encrypted |
webhooks |
secret |
Credential (HMAC signing key) | AES-256-GCM encrypted |
merchants |
api_key |
Credential | SHA-256 hash (one-way, not reversible) |
merchant_api_keys |
key_hash |
Credential | SHA-256 hash (one-way, not reversible) |
users |
password_hash |
Credential | bcrypt hash (one-way, not reversible) |
API keys and passwords are one-way hashed — they do not need to be decrypted at runtime, so reversible encryption is not appropriate for them.
- Algorithm: AES-256-GCM
- Key size: 256 bits (32 bytes)
- IV: 96-bit random IV generated per encryption operation
- Auth tag: 128-bit GCM authentication tag (prevents tampering)
Encrypted values are stored as a single base64 string:
base64( IV[12 bytes] || AuthTag[16 bytes] || Ciphertext[N bytes] )
Minimum stored length: ceil((12 + 16 + 1) / 3) * 4 = 40 base64 characters.
| Variable | Required | Description |
|---|---|---|
FIELD_ENCRYPTION_KEY |
Yes | Active 256-bit key as 64-char hex string |
FIELD_ENCRYPTION_KEY_PREVIOUS |
During rotation only | Previous key for decrypting old rows |
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Keys are never stored in the database or committed to source control. In production they are sourced from AWS Secrets Manager and injected as environment variables at container startup (see infra/secrets.tf).
Key rotation is a two-phase process with zero downtime.
- Generate a new 64-char hex key.
- In AWS Secrets Manager, update the secret to add
FIELD_ENCRYPTION_KEY_PREVIOUS(set to the current key value) and updateFIELD_ENCRYPTION_KEYto the new key. - Deploy the application. New writes use the new key; existing encrypted rows are still readable because
decrypt()falls back toFIELD_ENCRYPTION_KEY_PREVIOUS.
- Run the migration script against the production database:
FIELD_ENCRYPTION_KEY=<new-key> \
FIELD_ENCRYPTION_KEY_PREVIOUS=<old-key> \
DATABASE_URL=<prod-url> \
node novaRewards/scripts/encrypt-existing-rows.js --rotateThe script is idempotent — it detects already-rotated rows and skips them.
- Verify row counts in the script output match expectations.
- Once all rows are re-encrypted, remove
FIELD_ENCRYPTION_KEY_PREVIOUSfrom Secrets Manager. - Deploy the application again (or restart pods) to pick up the updated secret.
novaRewards/backend/lib/encryption.js
encrypt(plaintext)→ base64 ciphertext blobdecrypt(ciphertextBase64)→ plaintext (falls back to previous key during rotation)isEncrypted(value)→ boolean (used by migration script to detect already-encrypted rows)
novaRewards/backend/lib/prismaEncryptionMiddleware.js
Intercepts Prisma create/update/upsert operations to encrypt configured fields before the query, and find* operations to decrypt after. The ENCRYPTED_FIELDS map controls which model fields are encrypted.
For tables accessed via raw pg queries (users, webhooks), encryption/decryption is applied in the repository layer:
novaRewards/backend/db/userRepository.js—encryptEmail/decryptUserRownovaRewards/backend/db/webhookRepository.js—encryptWebhookSecret/decryptWebhookRownovaRewards/backend/routes/auth.js— encrypts email on register/login before DB lookup
Because AES-256-GCM uses a random IV, the same plaintext produces a different ciphertext each time. The UNIQUE index on users.email has been dropped (migration 019). Uniqueness is now enforced at the application layer: the auth route catches Postgres 23505 unique-violation errors, but the primary guard is the encrypted lookup — the same encrypted value is stored consistently per user because we always encrypt the normalized (lowercased, trimmed) email before lookup.
Note: If you need to search users by email (e.g. admin lookup), encrypt the search term first using
encrypt(email.trim().toLowerCase())and compare against the stored value. Wildcard/ILIKE searches on encrypted email are not possible — use thefirst_name,last_name, orwallet_addresscolumns for search instead.
Unit tests for the encryption utility:
cd novaRewards/backend
npx jest tests/encryption.test.js --runInBandIntegration tests covering the full encrypt-on-write / decrypt-on-read cycle are in:
novaRewards/backend/tests/auth.test.js(email encryption through register/login)novaRewards/backend/tests/security/security.test.js(field-level encryption assertions)