Overview
PensionPortal.ai handles sensitive pension data for Irish occupational pension schemes. The platform is subject to IORP II (S.I. 128/2021), DORA, and GDPR compliance requirements. This document describes the security controls applied at every layer of the stack.
Authentication
Auth.js v5 JWT Strategy
Authentication is handled by Auth.js v5 using the Credentials provider with a JWT session strategy. Sessions are stateless — no server-side session store is required.
// auth.ts (simplified)
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
async authorize(credentials) {
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.email, credentials.email))
.limit(1);
if (!user[0]) return null;
const passwordValid = await bcryptjs.compare(
credentials.password,
user[0].passwordHash
);
if (!passwordValid) return null;
return user[0];
},
}),
],
session: { strategy: "jwt" },
});
Password Hashing
All passwords are hashed with bcryptjs (bcrypt algorithm, work factor 12) before storage. Plaintext passwords are never persisted or logged.
// At user creation
const passwordHash = await bcryptjs.hash(plaintextPassword, 12);
// At authentication
const valid = await bcryptjs.compare(plaintextPassword, storedHash);
Development fallback accounts (admin123, broker123) exist in auth.ts and are guarded by if (process.env.NODE_ENV === 'production') return null. These credentials MUST NOT be active in production. See Development Hardcoded Credentials below.
Password Reset Flow
PensionPortal.ai supports a secure self-service password reset flow for all user roles.
Flow:
- User clicks “Forgot password?” on the login page.
- User submits their email address to
POST /api/auth/forgot-password.
- Server generates a cryptographically secure 32-byte random token.
- Token is SHA-256 hashed before storage in
password_reset_tokens table (plaintext never persisted).
- A reset email is sent via Resend with a link containing the plaintext token.
- User clicks the link and sets a new password via
POST /api/auth/reset-password.
- Server validates the token hash, checks expiry (60 minutes), and updates the password.
- Token is marked as used (one-time use) to prevent replay.
Security Controls:
| Control | Implementation |
|---|
| User enumeration prevention | Always returns generic “check your email” response regardless of whether the email exists |
| Token security | 32 bytes of crypto.randomBytes, SHA-256 hashed before storage |
| Token expiry | 60 minutes from generation |
| One-time use | Token marked as usedAt after successful password reset |
| Existing token invalidation | All unused tokens for the user are invalidated when a new reset is requested |
| Rate limiting | Cloudflare WAF: 10 requests/minute on /api/auth/* |
| Password hashing | New password hashed with bcryptjs (work factor 12) |
| No PII in logs | Email addresses never logged; only operation status |
POST /api/auth/forgot-password → { email } → 200 (always)
POST /api/auth/reset-password → { token, password } → 200 or 400
Code locations:
- API routes:
src/app/api/auth/forgot-password/route.ts, src/app/api/auth/reset-password/route.ts
- Token schema:
src/db/schema/password-resets.ts
- Email template:
src/lib/email/templates/password-reset.ts
- UI pages:
src/app/auth/forgot-password/page.tsx, src/app/auth/reset-password/page.tsx
Authorization
Role-Based Access Control (RBAC)
Authorization is enforced at the service layer via ActorContext. Two primary guard functions are used:
| Function | Purpose | Throws |
|---|
requireRole(actor, minimumRole) | Asserts actor has sufficient role level | 403 Forbidden |
requireTenant(actor, targetTenantId) | Asserts actor belongs to the target tenant | 403 Forbidden |
Role Hierarchy
SuperAdmin > BrokerAdmin > BrokerUser > EmployerAdmin > EmployerUser > Member
Roles are encoded as an ordered enum. requireRole checks that roleLevel(actor.role) >= roleLevel(minimumRole).
Service Layer Enforcement
Every service method that touches protected data enforces both role and tenant:
async function deleteScheme(
schemeId: string,
actor: ActorContext
): Promise<void> {
requireRole(actor, "BrokerAdmin"); // minimum role
requireTenant(actor, scheme.employer.brokerId); // tenant isolation
// proceed with deletion...
}
See Multi-Tenancy Architecture for full details on tenant enforcement.
Transport Security
| Control | Implementation |
|---|
| Protocol | HTTPS only (TLS 1.3) |
| Enforcement | Vercel platform + Cloudflare proxy |
| HSTS | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload |
| HTTP redirect | All HTTP traffic permanently redirected to HTTPS (301) |
| Certificate | Managed by Vercel (Let’s Encrypt / Cloudflare) — auto-renewed |
PPS Number Encryption (GDPR Priority 0)
Personal Public Service (PPS) numbers are highly sensitive personal identifiers under Irish GDPR. They receive the highest classification in the data protection model.
Encryption Scheme
- Algorithm: AES-256-GCM (authenticated encryption — provides both confidentiality and integrity)
- Key size: 256-bit (32 bytes), stored as 64-character hex string in
PPS_ENCRYPTION_KEY
- Storage: Encrypted ciphertext stored in
members.ppsNumberEncrypted — plaintext never persisted
- IV: Randomly generated per encryption operation, prepended to ciphertext
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(process.env.PPS_ENCRYPTION_KEY!, "hex"); // 32 bytes
export function encryptPps(plaintext: string): string {
const iv = randomBytes(12); // 96-bit IV for GCM
const cipher = createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
// Store as: iv(12) + authTag(16) + ciphertext — all base64 encoded
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
export function decryptPps(ciphertext: string): string {
const buf = Buffer.from(ciphertext, "base64");
const iv = buf.subarray(0, 12);
const authTag = buf.subarray(12, 28);
const encrypted = buf.subarray(28);
const decipher = createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
Access Controls
- PPS numbers are never decrypted for display without an explicit broker action
- Every decryption event is written to
audit_logs with actor identity
- PPS numbers are never sent to AI providers (Anthropic API receives only non-PII aggregate data)
- PPS numbers are never logged in application logs or error traces
Key Generation
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store the output as PPS_ENCRYPTION_KEY in Vercel encrypted environment variables. Rotate on schedule (see Environment Variable Security).
Audit Logging (IORP II Requirement)
Audit logging is a regulatory requirement under IORP II (S.I. 128/2021) for pension scheme oversight. Every state-changing operation must be traceable.
Audit Log Schema
// audit_logs table
{
id: string, // CUID2
actorId: string, // users.id of the actor
actorType: Role, // role at time of action
action: string, // e.g. "scheme.created", "member.pps_decrypted"
entityType: string, // e.g. "scheme", "member", "writtenPolicy"
entityId: string, // PK of the affected entity
previousState: JsonB, // state before the action (null for creates)
newState: JsonB, // state after the action (null for deletes)
ipAddress: string, // from x-forwarded-for header
userAgent: string, // from user-agent header
timestamp: timestamp // server-assigned UTC timestamp
}
Append-Only Guarantee
The audit_logs table has no application-layer DELETE or UPDATE operations. Database-level permissions restrict modification:
- No
DELETE privilege granted to the application database role
- No
UPDATE privilege granted to the application database role
- Reads are permitted for SuperAdmin and BrokerAdmin (own tenant only)
Audit logs are retained indefinitely per regulatory requirements. See Data Retention.
API Security
Authentication Gate
All /api/* routes (except /api/auth/* and /api/health/*) require an authenticated session. The Next.js middleware enforces this before any route handler runs:
// middleware.ts
export async function middleware(request: NextRequest) {
const session = await auth();
const isApiRoute = request.nextUrl.pathname.startsWith("/api/");
const isAuthRoute = request.nextUrl.pathname.startsWith("/api/auth/");
const isHealthRoute = request.nextUrl.pathname.startsWith("/api/health/");
if (isApiRoute && !isAuthRoute && !isHealthRoute && !session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
All POST and PUT request bodies are validated with Zod schemas before any service method is called. Invalid input returns 400 Bad Request with a structured error:
const CreateSchemeSchema = z.object({
employerId: z.string().cuid2(),
name: z.string().min(1).max(200),
type: z.enum(["DB", "DC", "PRSA", "RAC"]),
});
const parsed = CreateSchemeSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
);
}
SQL Injection Prevention
All database queries use Drizzle ORM with parameterised queries. String interpolation into raw SQL is not used anywhere in the codebase. Drizzle’s query builder compiles to parameterised prepared statements, eliminating SQL injection as an attack surface.
AI Data Minimization
The Anthropic Claude API is used for compliance document drafting and regulatory Q&A (RAG). Strict data minimization is enforced:
What Is Sent to Anthropic
| Data type | Sent to AI? | Notes |
|---|
| Scheme name | Yes | Non-PII operational data |
| Scheme type (DB/DC) | Yes | Regulatory classification |
| KFH count, policy count | Yes | Aggregate counts only |
| Regulatory corpus chunks | Yes | Public IORP II documents only |
| Member names | Never | PII — excluded |
| Date of birth | Never | PII — excluded |
| PPS numbers | Never | PII — excluded (encrypted at rest, never decrypted for AI) |
| Email addresses | Never | PII — excluded |
| Contribution amounts | Never | PII — excluded |
RAG Corpus
The pgvector RAG corpus contains only public regulatory text — extracts from S.I. 128/2021 (IORP II transposition), Pensions Authority guidance, and Revenue Commissioner circulars. No member or scheme-specific data is embedded in the vector store.
Dependency Security
| Severity | Count | Location | Impact |
|---|
| Moderate | 4 | drizzle-kit / esbuild ecosystem | Dev tooling only — not present in production runtime |
| High | 1 | minimatch (indirect, dev dependency) | Dev tooling only — not present in production runtime |
| Critical | 0 | — | — |
Production runtime (the deployed Next.js application) has no known vulnerable packages. Vulnerabilities exist only in dev-time build tools.
npm audit is run on every CI build. PRs that introduce new production vulnerabilities are blocked by the CI pipeline.
Environment Variable Security
All secrets are stored in Vercel encrypted environment variables — never in source code or committed to git.
# .gitignore covers all env files
.env
.env.*
.env.local
.env.production
Secrets Inventory
| Variable | Purpose | Rotation Schedule |
|---|
AUTH_SECRET | Auth.js JWT signing key | Annually or on suspected compromise |
PPS_ENCRYPTION_KEY | AES-256-GCM key for PPS numbers | Annually or on suspected compromise |
AI_KEY_ENCRYPTION_SECRET | AES-256-GCM key for BYOK tenant AI provider keys | Annually or on suspected compromise |
DATABASE_URL | Neon PostgreSQL connection string | On credential rotation |
DATABASE_URL_UNPOOLED | Neon unpooled connection (migrations) | On credential rotation |
ANTHROPIC_API_KEY | Anthropic Claude API | On rotation by Anthropic |
RESEND_API_KEY | Resend transactional email | On rotation by Resend |
ADMIN_EMAIL | System notifications recipient | On team change |
EMAIL_FROM | Sender email address | On domain change |
CRON_SECRET | Bearer token for scheduled cron endpoints (/api/cron/*) | Annually or on suspected compromise |
RAG_INGEST_SECRET | Bearer token protecting POST /api/rag/ingest | Annually or on suspected compromise |
OPENROUTER_API_KEY | OpenRouter alternative AI provider (optional) | On rotation by OpenRouter |
BLOB_READ_WRITE_TOKEN | Vercel Blob storage | On Vercel credential rotation |
Rotating PPS_ENCRYPTION_KEY requires a migration to re-encrypt all existing PPS values. See the PPS Encryption Key Rotation Procedure in the Incident Response runbook. Do not rotate without following the procedure.
Development Hardcoded Credentials
auth.ts contains fallback development accounts for local testing:
// auth.ts — development fallback only
if (process.env.NODE_ENV === "production") return null; // guard
const DEV_USERS = [
{ email: "admin@test.ie", password: "admin123", role: "SuperAdmin" },
{ email: "broker@test.ie", password: "broker123", role: "BrokerAdmin" },
];
These accounts:
- Are completely disabled in production (guarded by
NODE_ENV check)
- Are documented as dev-only in code comments
- Use trivially guessable passwords intentionally (they never reach production)
- Are excluded from production builds via the
NODE_ENV guard
These credentials MUST NOT appear in production. Verify NODE_ENV=production is set in all production and staging deployment environments. The CI pipeline asserts this as part of the deployment checklist.
Cloudflare WAF
All production traffic passes through Cloudflare before reaching Vercel:
| Control | Configuration |
|---|
| OWASP Core Ruleset | Enabled — blocks SQLi, XSS, path traversal |
| Rate limiting | 100 requests / minute per IP on /api/*; 10 requests / minute on /api/auth/* |
| Bot protection | Cloudflare Bot Management — blocks known scrapers and credential stuffing tools |
| DDoS mitigation | Cloudflare Anycast network — automatic L3/L4 DDoS absorption |
| Geo-blocking | Ireland and EU allowlisted; high-risk regions challenge-mode |
See deployment/cloudflare.mdx for full WAF rule configuration.
The following security headers are set on all responses via next.config.js:
| Header | Value | Purpose |
|---|
Content-Security-Policy | default-src 'self'; script-src 'self' 'nonce-{nonce}'; ... | Prevents XSS and resource injection |
X-Frame-Options | DENY | Prevents clickjacking in iframes |
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
Referrer-Policy | strict-origin-when-cross-origin | Limits referrer information leakage |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disables unused browser APIs |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Enforces HTTPS for 2 years |
// next.config.js (headers excerpt)
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
Compliance Cross-Reference
| Requirement | Control | Standard |
|---|
| PPS number protection | AES-256-GCM encryption + access logging | GDPR Art. 9, Data Protection Act 2018 |
| Audit trail | Append-only audit_logs table | IORP II S.I. 128/2021, Art. 22 |
| Access control | RBAC via ActorContext + requireRole/requireTenant | IORP II, DORA Art. 9 |
| Data minimization | AI input filtering (no PII to Anthropic) | GDPR Art. 5(1)(c) |
| Incident logging | Audit logs with IP + userAgent | DORA Art. 17 |
| Secure transmission | TLS 1.3 + HSTS | GDPR Art. 32, DORA Art. 9 |