PII Encryption at Rest
PII Encryption at Rest
As of v0.1.134, the three personally identifiable fields stored in the people table are encrypted at rest using AES-256-GCM before every database write and decrypted transparently on every read.
This satisfies the technical security measures required under GDPR Article 32.
Encrypted fields
| Field | Table | Notes |
|---|---|---|
fullName | people | Person or entity name |
dateOfBirth | people | Individuals only |
nationality | people | Individuals only |
All other fields (e.g. registrationNumber, referenceId, status) are stored as plaintext.
How it works
A centralised repository layer (src/lib/people-repo.ts) enforces encryption and decryption at every database boundary. All API routes and internal services interact with the people table through this layer — no code path can write or read PII without going through it.
Write path
When a person or entity is created or updated, PII fields are encrypted before the SQL statement is issued:
API route / batch engine
│
▼
insertPerson() / updatePerson() ← people-repo.ts
│ encrypts fullName, dateOfBirth, nationality
▼
db.insert(people) / db.update(people)
│
▼
Database (ciphertext stored)
Read path
After every db.select(), the result is passed through decryptPersonRow() or decryptPersonRows() before being returned to the caller:
Database (ciphertext)
│
▼
db.select(people)
│
▼
decryptPersonRow(s)() ← people-repo.ts
│ decrypts fullName, dateOfBirth, nationality
▼
API response / screening engine (plaintext)
Encryption algorithm
Fields are encrypted using AES-256-GCM. Encrypted values are stored with an enc: prefix so the system can distinguish ciphertext from legacy plaintext rows. The encryption key is read from the ENCRYPTION_SECRET environment variable (enforced since SEC-06).
Backwards compatibility
No database migration is required. The decryption helpers detect whether a value carries the enc: prefix:
enc:...→ decrypted usingENCRYPTION_SECRET- Anything else → returned as-is (legacy plaintext pass-through)
This means existing plaintext rows continue to work without modification alongside newly-encrypted rows.
Affected endpoints
All of the following endpoints now transparently encrypt/decrypt PII. No change to request or response formats is required — callers always send and receive plaintext values.
| Endpoint | Change |
|---|---|
POST /api/people | Inserts encrypted; returns decrypted |
GET /api/people | Returns decrypted list |
GET /api/people/{id} | Returns decrypted detail |
DELETE /api/people/{id} | Decrypts fullName for audit log |
GET /api/people/gdpr-export | Exports decrypted plaintext |
POST /api/v1/people | Inserts encrypted; returns decrypted |
GET /api/v1/people | Returns decrypted list |
POST /api/v1/screen (with save=true) | Inserts encrypted |
Batch rescreening
The nightly batch rescreen engine fetches people in chunks and passes names to the fuzzy matcher. As of this release, each chunk is decrypted immediately after the database fetch, so the screenPersonAgainstList() engine always receives plaintext names. Encrypted ciphertext is never passed to the matcher.
Known limitation — full-text search on encrypted fields
Server-side ILIKE search on fullName does not match encrypted rows because the ciphertext does not resemble the original name string.
What still works:
- Search by
referenceId - Search by
registrationNumber - Search against legacy plaintext rows
Encrypted-field search requires a blind-index approach and is tracked as a separate work item.
Configuration
Encryption requires the ENCRYPTION_SECRET environment variable to be set. This was enforced in a prior release (SEC-06). No additional configuration is needed for this release.
# .env.local
ENCRYPTION_SECRET=<32-byte hex or random string>
If ENCRYPTION_SECRET is not set, the application will refuse to start.