All Docs
FeaturesCalmony Sanctions MonitorUpdated March 12, 2026

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

FieldTableNotes
fullNamepeoplePerson or entity name
dateOfBirthpeopleIndividuals only
nationalitypeopleIndividuals 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 using ENCRYPTION_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.

EndpointChange
POST /api/peopleInserts encrypted; returns decrypted
GET /api/peopleReturns decrypted list
GET /api/people/{id}Returns decrypted detail
DELETE /api/people/{id}Decrypts fullName for audit log
GET /api/people/gdpr-exportExports decrypted plaintext
POST /api/v1/peopleInserts encrypted; returns decrypted
GET /api/v1/peopleReturns 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.