Milestone: M9 — Hardening and Compliance | SOW Reference: NFR3 (cross-cutting audit) | Requirement Clarity: ✅ Clear | Dev Status: 🟡 Partially prototyped Moved from
docs/requirements/tasks/POC/15-security-encryption-audit.md(audit dated 2026-04-15). Cross-checks the earlier codebase review done during today's SOW comparison — treat this as the detailed per-entity backing evidence for the summary findings in ../CONSOLIDATED-SUMMARY.md. Unmodified below.
M9-00 Task 15: Security & Encryption Audit¶
Overview¶
Comprehensive security audit mapping v3.0 requirements to current implementation. Covers data flow, encryption at rest, authentication, and compliance (GDPR, UKDIATF).
Audit Date: 2026-04-15 Source: REC Verifiable Credentialing Platform High-level Requirements v3.0 (Section: Security, Privacy and Compliance, pages 4-5)
Part 1: Data Flow Confirmation¶
✅ Correct Flow (Confirmed)¶
┌──────────────┐ 1. HTTPS (TLS 1.3) ┌──────────────┐
│ Frontend │ ─────────────────────→ │ Backend │
│ (Next.js) │ │ (NestJS) │
│ localhost: │ ←───────────────────── │ localhost: │
│ 3001 │ 5. Response to UI │ 3000 │
└──────────────┘ └──────┬───────┘
│
│ 2. HTTPS (TLS 1.3)
│ Bearer Token Auth
▼
┌──────────────┐
│ Velocity │
│ CIH API │
│ stagingcih. │
│ velocity... │
└──────┬───────┘
│
│ 3. Response
▼
┌──────────────┐
│ Backend │
│ Processes │
│ & stores │
│ in DB │
└──────┬───────┘
│
│ 4. PostgreSQL
▼
┌──────────────┐
│ PostgreSQL │
│ (encrypted │
│ at rest) │
└──────────────┘
Security touchpoints in this flow: 1. Frontend → Backend: TLS 1.3 + JWT (Auth0) 2. Backend → Velocity: TLS 1.3 + Bearer Token 3. Backend → Backend processing: In-memory only 4. Backend → PostgreSQL: Connection TLS + column-level encryption for PII 5. Backend → Frontend: TLS 1.3 + JWT session + @Exclude() on sensitive fields
The frontend NEVER talks to Velocity directly. This is correct — it ensures: - Bearer tokens stay on backend (never exposed to browser) - All requests audited through backend - Rate limiting enforced server-side - Webhook handling centralized
Part 2: v3.0 Security Requirements vs Current Implementation¶
Requirements (from v3.0 PDF, page 4)¶
| # | Requirement | Current Status | Gap |
|---|---|---|---|
| S1 | Zero trust architecture | ⚠️ Partial | See details below |
| S2 | Integrates with OAuth 2.0, SAML 2.0, OpenID Connect | ✅ Done | None (Auth0) |
| S3 | Encrypt all personal data at rest (AES-256) | ❌ Missing | CRITICAL |
| S4 | Encrypt all data in transit (TLS 1.3) | ✅ Done | None (HTTPS) |
| S5 | Detailed immutable audit logs | ⚠️ Partial | Logs exist, not immutable |
| S6 | RBAC (role-based access control) | ✅ Done | None |
| S7 | UKDIATF compliance | ⚠️ Unknown | Need legal review |
| S8 | GDPR compliance (data minimisation, SAR, right to erasure) | ❌ Missing | CRITICAL |
| S9 | UK staffing industry data retention | ❌ Missing | HIGH |
| S10 | Data segregation between companies (multi-tenancy) | ✅ Done | org_id scoping |
| S11 | CCPA, SOC2 compliance | ⚠️ Partial | Framework in place |
| S12 | DDoS protection | ❌ Missing | Deployment concern |
| S13 | Penetration testing | ❌ Not done | Required pre-release |
Part 3: Encryption Audit — Database Tables¶
What IS Already Encrypted¶
| Entity | Field | Method | Status |
|---|---|---|---|
organizations |
velocity_tenant_keys |
AES-256-CBC (app-level) | ⚠️ Uses deprecated createCipher API |
What NEEDS Encryption (per v3.0 Section S3)¶
CRITICAL — Sensitive Credentials¶
| Entity | Field | Why Sensitive | Priority |
|---|---|---|---|
accounts |
access_token, refresh_token, id_token |
OAuth tokens in plaintext | CRITICAL |
nextauth_sessions |
session_token |
Session hijacking risk | CRITICAL |
verification_tokens |
token |
Auth bypass risk | CRITICAL |
HIGH — PII Fields (GDPR)¶
| Entity | Field | Why Sensitive | Priority |
|---|---|---|---|
users |
email, first_name, last_name, phone |
PII | HIGH |
employees |
email, first_name, last_name, phone |
PII | HIGH |
employees |
salary_amount, performance_rating |
Compensation data | HIGH |
employees |
supervisor_email, supervisor_name |
PII (third party) | HIGH |
employees |
reason_for_leaving, additional_notes |
Sensitive HR data | HIGH |
candidates |
first_name, last_name, email, phone |
PII | HIGH |
credential_offers |
payload (JSONB) |
Full credential = PII | HIGH |
email_logs |
recipient_email, recipient_name, variables |
PII in logs | HIGH |
MEDIUM — Supporting Data¶
| Entity | Field | Why Sensitive | Priority |
|---|---|---|---|
audit_logs |
before, after (JSONB) |
Contains PII snapshots | MEDIUM |
kyb_records |
registrar_payload, evidence |
Corporate docs | MEDIUM |
verification_reports |
result (JSONB) |
Verification data | MEDIUM |
sessions |
ip, user_agent |
Tracking data | LOW |
Part 4: Immediate Action Items (POC-Critical Subset)¶
Not everything can be fixed before POC demo. These are the minimum security fixes to show the client good hygiene without scope creep:
Must Fix for POC (~4-6 hours)¶
- Fix deprecated crypto API in
velocity-tenant.service.ts - Replace
createCipher()/createDecipher()(deprecated, insecure) - Use
createCipheriv()/createDecipheriv()with random IV -
Effort: 1 hour
-
Enforce
TENANT_KEYS_ENCRYPTION_KEYenvironment variable - Remove the insecure default
'default-key-change-in-production' - App should fail to start if not set in production
-
Effort: 30 min
-
Encrypt OAuth tokens in
accountstable - Add TypeORM transformer for
access_token,refresh_token,id_token - Use same AES-256-CBC approach as
velocityTenantKeys -
Effort: 2 hours
-
Mark sensitive fields with
@Exclude() - Prevent leaking through class-transformer in API responses
- Fields: tokens, raw keys, encryption metadata
-
Effort: 30 min
-
Add security section to demo
- Slide/doc showing what's encrypted, what's logged, what's audited
- Shows client we take security seriously
- Effort: 1 hour (no code)
Defer to Drop 1 Beta (Post-POC)¶
- Column-level PII encryption (employees, candidates, users)
- GDPR features (data export, right to erasure)
- Audit log immutability (append-only table, signed records)
- Data retention policies (auto-disposal after period)
- Penetration testing
- DDoS protection (Cloudflare, AWS Shield)
Part 5: Recommended Encryption Pattern (for Implementation)¶
TypeORM Transformer Approach¶
Create a reusable encryption transformer:
// app/backend/src/shared/transformers/encrypted.transformer.ts
import { ValueTransformer } from 'typeorm';
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
export class EncryptedColumnTransformer implements ValueTransformer {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
constructor() {
const keyHex = process.env.COLUMN_ENCRYPTION_KEY;
if (!keyHex || keyHex.length !== 64) {
throw new Error('COLUMN_ENCRYPTION_KEY must be 32-byte hex string');
}
this.key = Buffer.from(keyHex, 'hex');
}
to(value: string | null): string | null {
if (value == null) return null;
const iv = randomBytes(12);
const cipher = createCipheriv(this.algorithm, this.key, iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${ciphertext.toString('hex')}`;
}
from(value: string | null): string | null {
if (value == null) return null;
const [ivHex, tagHex, ctHex] = value.split(':');
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ciphertext = Buffer.from(ctHex, 'hex');
const decipher = createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
}
export const encryptedColumn = new EncryptedColumnTransformer();
Usage on Entity Fields¶
import { encryptedColumn } from '../../shared/transformers/encrypted.transformer';
@Entity('accounts')
export class Account {
// ...
@Column({ type: 'text', nullable: true, transformer: encryptedColumn })
accessToken: string;
@Column({ type: 'text', nullable: true, transformer: encryptedColumn })
refreshToken: string;
@Column({ type: 'text', nullable: true, transformer: encryptedColumn })
idToken: string;
}
Why AES-256-GCM Over AES-256-CBC¶
| Feature | GCM (recommended) | CBC (current) |
|---|---|---|
| Authenticated encryption | ✅ Built-in auth tag | ❌ Needs separate HMAC |
| Detects tampering | ✅ Yes | ❌ No |
| Padding attacks | ✅ Immune | ⚠️ Vulnerable |
| NIST recommended | ✅ Yes | Legacy only |
| Performance | Faster | Slower |
Part 6: Model/Table Updates Needed¶
Summary Table¶
| Action | Entity | Fields | When |
|---|---|---|---|
| Fix | organizations |
velocity_tenant_keys |
POC (fix deprecated API) |
| Fix | N/A | Remove default-key fallback |
POC |
| Add encryption | accounts |
access_token, refresh_token, id_token |
POC |
| Add encryption | nextauth_sessions |
session_token |
POC |
| Add @Exclude() | Multiple | Token fields, encryption metadata | POC |
| Add encryption | employees |
salary_amount, performance_rating |
Drop 1 Beta |
| Add encryption | users |
email, phone, first_name, last_name |
Drop 1 Beta |
| Add encryption | employees |
email, phone, name fields |
Drop 1 Beta |
| Add encryption | candidates |
PII fields | Drop 1 Beta |
| Add encryption | credential_offers |
payload JSONB |
Drop 1 Beta |
| Add encryption | email_logs |
variables JSONB |
Drop 1 Beta |
| Add encryption | audit_logs |
before, after JSONB |
Drop 1 Beta |
| Add retention | email_logs, audit_logs |
Auto-purge after 30-90 days | Drop 1 Beta |
| Add immutability | audit_logs |
Append-only, signed records | Drop 2 |
New Environment Variables Needed¶
# Column-level encryption (AES-256-GCM)
# Generate with: openssl rand -hex 32
COLUMN_ENCRYPTION_KEY=<32-byte hex string, REQUIRED in production>
# Tenant keys encryption (existing, but enforce)
TENANT_KEYS_ENCRYPTION_KEY=<32-byte hex string, REQUIRED in production>
# Data retention policies
AUDIT_LOG_RETENTION_DAYS=2555 # 7 years for UK staffing compliance
EMAIL_LOG_RETENTION_DAYS=90
SESSION_RETENTION_DAYS=30
Part 7: Compliance Checklist for Client¶
✅ What We Can Show the Client NOW¶
- Multi-factor authentication (Auth0 MFA) — S2
- OAuth 2.0 / OpenID Connect integration — S2
- TLS 1.3 in transit — S4
- Role-based access control (Platform Admin, Org Admin, Org User) — S6
- Multi-tenant data segregation (every table has
org_id) — S10 - Session management with expiration — S6
- Audit logging (every action tracked) — S5 (partial)
- Velocity tenant keys encrypted at rest — S3 (partial)
⚠️ What We'll Address in POC (~6 hours)¶
- Fix deprecated crypto API → use modern AES-256-GCM
- Encrypt OAuth tokens in
accountstable - Enforce required encryption keys (no insecure defaults)
- Mark sensitive fields @Exclude() in API responses
⏳ What's Planned for Drop 1 Beta¶
- Full PII encryption on employees, candidates, users, credentials
- Encrypted audit log state snapshots
- Data retention policies
⏳ What's Planned for Drop 2 / Production¶
- GDPR features (SAR, right to erasure)
- Audit log immutability with cryptographic signatures
- Penetration testing (third-party)
- DDoS protection (Cloudflare / AWS Shield)
- WAF rules
- SOC 2 compliance audit
Part 8: Security Talking Points for Client Demo¶
If security is raised in the demo, these are prepared answers:
Q: "How do you encrypt data?" A: "Data in transit is TLS 1.3. Data at rest uses AES-256-GCM column-level encryption for tokens, keys, and PII. The database itself runs on encrypted PostgreSQL instances (disk-level)."
Q: "What about GDPR?"
A: "Architecture supports GDPR: every record has org_id scoping, audit logs track all access, soft-delete with deleted_at enables right-to-erasure, and we'll implement data subject access requests in Drop 1 Beta before production."
Q: "Who can access what?" A: "Role-based access control with 4 levels: Platform Admin, Org Admin, Org User, Public (claim pages). Every API endpoint is guarded by role + organization scoping. All requests audited."
Q: "What if the database is compromised?"
A: "Attacker would need: (1) TLS private keys for connection, (2) TENANT_KEYS_ENCRYPTION_KEY + COLUMN_ENCRYPTION_KEY from env vars (stored separately), (3) Auth0 access. Defense in depth."
Q: "How do you handle credentials (passwords)?" A: "We don't store passwords. Authentication is delegated to Auth0 with MFA. We only store Auth0 IDs. Optional passkey support (WebAuthn) is in the architecture for Drop 1."
Q: "What about the Velocity bearer token? Isn't that a secret?" A: "Yes — it's stored in backend environment variables only, never exposed to frontend. All Velocity API calls happen server-side. The token has limited scope (CAO operations only) and rotation can be requested from Velocity."
Part 9: Next Steps¶
For POC (minimum security hygiene)¶
- Create
EncryptedColumnTransformerusing AES-256-GCM - Fix
velocity-tenant.service.tsdeprecated API - Remove insecure default encryption key
- Add
COLUMN_ENCRYPTION_KEYto.env - Apply transformer to
accounts.access_token,refresh_token,id_token - Apply transformer to
nextauth_sessions.session_token - Mark token fields with
@Exclude() - Document security posture (this file + client talking points)
For Drop 1 Beta¶
- Apply encryption to all PII fields (users, employees, candidates)
- Encrypt credential offer payloads
- Encrypt audit log state snapshots
- Implement data retention policies
- Add GDPR data export endpoint
- Add right-to-erasure workflow
For Drop 2 / Production¶
- Third-party penetration testing
- DDoS protection
- WAF configuration
- SOC 2 audit
- UKDIATF compliance review (legal)
- Audit log signing / immutability
- Key rotation automation
- HSM consideration for key management
References¶
- v3.0 Requirements PDF (Security, Privacy and Compliance section, pages 4-5)
- Task 10: Gap Analysis — Non-functional requirements tracking
- Task 14: POC Readiness Audit — Overall readiness
- Node.js Crypto docs: https://nodejs.org/api/crypto.html (for AES-256-GCM)
- NIST SP 800-38D (Authenticated Encryption)
- GDPR Article 32 (Security of Processing)