Skip to main content

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:
  1. User clicks “Forgot password?” on the login page.
  2. User submits their email address to POST /api/auth/forgot-password.
  3. Server generates a cryptographically secure 32-byte random token.
  4. Token is SHA-256 hashed before storage in password_reset_tokens table (plaintext never persisted).
  5. A reset email is sent via Resend with a link containing the plaintext token.
  6. User clicks the link and sets a new password via POST /api/auth/reset-password.
  7. Server validates the token hash, checks expiry (60 minutes), and updates the password.
  8. Token is marked as used (one-time use) to prevent replay.
Security Controls:
ControlImplementation
User enumeration preventionAlways returns generic “check your email” response regardless of whether the email exists
Token security32 bytes of crypto.randomBytes, SHA-256 hashed before storage
Token expiry60 minutes from generation
One-time useToken marked as usedAt after successful password reset
Existing token invalidationAll unused tokens for the user are invalidated when a new reset is requested
Rate limitingCloudflare WAF: 10 requests/minute on /api/auth/*
Password hashingNew password hashed with bcryptjs (work factor 12)
No PII in logsEmail 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:
FunctionPurposeThrows
requireRole(actor, minimumRole)Asserts actor has sufficient role level403 Forbidden
requireTenant(actor, targetTenantId)Asserts actor belongs to the target tenant403 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

ControlImplementation
ProtocolHTTPS only (TLS 1.3)
EnforcementVercel platform + Cloudflare proxy
HSTSStrict-Transport-Security: max-age=63072000; includeSubDomains; preload
HTTP redirectAll HTTP traffic permanently redirected to HTTPS (301)
CertificateManaged 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 });
  }
}

Input Validation

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 typeSent to AI?Notes
Scheme nameYesNon-PII operational data
Scheme type (DB/DC)YesRegulatory classification
KFH count, policy countYesAggregate counts only
Regulatory corpus chunksYesPublic IORP II documents only
Member namesNeverPII — excluded
Date of birthNeverPII — excluded
PPS numbersNeverPII — excluded (encrypted at rest, never decrypted for AI)
Email addressesNeverPII — excluded
Contribution amountsNeverPII — 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

SeverityCountLocationImpact
Moderate4drizzle-kit / esbuild ecosystemDev tooling only — not present in production runtime
High1minimatch (indirect, dev dependency)Dev tooling only — not present in production runtime
Critical0
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

VariablePurposeRotation Schedule
AUTH_SECRETAuth.js JWT signing keyAnnually or on suspected compromise
PPS_ENCRYPTION_KEYAES-256-GCM key for PPS numbersAnnually or on suspected compromise
AI_KEY_ENCRYPTION_SECRETAES-256-GCM key for BYOK tenant AI provider keysAnnually or on suspected compromise
DATABASE_URLNeon PostgreSQL connection stringOn credential rotation
DATABASE_URL_UNPOOLEDNeon unpooled connection (migrations)On credential rotation
ANTHROPIC_API_KEYAnthropic Claude APIOn rotation by Anthropic
RESEND_API_KEYResend transactional emailOn rotation by Resend
ADMIN_EMAILSystem notifications recipientOn team change
EMAIL_FROMSender email addressOn domain change
CRON_SECRETBearer token for scheduled cron endpoints (/api/cron/*)Annually or on suspected compromise
RAG_INGEST_SECRETBearer token protecting POST /api/rag/ingestAnnually or on suspected compromise
OPENROUTER_API_KEYOpenRouter alternative AI provider (optional)On rotation by OpenRouter
BLOB_READ_WRITE_TOKENVercel Blob storageOn 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:
ControlConfiguration
OWASP Core RulesetEnabled — blocks SQLi, XSS, path traversal
Rate limiting100 requests / minute per IP on /api/*; 10 requests / minute on /api/auth/*
Bot protectionCloudflare Bot Management — blocks known scrapers and credential stuffing tools
DDoS mitigationCloudflare Anycast network — automatic L3/L4 DDoS absorption
Geo-blockingIreland and EU allowlisted; high-risk regions challenge-mode
See deployment/cloudflare.mdx for full WAF rule configuration.

Security Headers

The following security headers are set on all responses via next.config.js:
HeaderValuePurpose
Content-Security-Policydefault-src 'self'; script-src 'self' 'nonce-{nonce}'; ...Prevents XSS and resource injection
X-Frame-OptionsDENYPrevents clickjacking in iframes
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
Referrer-Policystrict-origin-when-cross-originLimits referrer information leakage
Permissions-Policycamera=(), microphone=(), geolocation=()Disables unused browser APIs
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadEnforces 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

RequirementControlStandard
PPS number protectionAES-256-GCM encryption + access loggingGDPR Art. 9, Data Protection Act 2018
Audit trailAppend-only audit_logs tableIORP II S.I. 128/2021, Art. 22
Access controlRBAC via ActorContext + requireRole/requireTenantIORP II, DORA Art. 9
Data minimizationAI input filtering (no PII to Anthropic)GDPR Art. 5(1)(c)
Incident loggingAudit logs with IP + userAgentDORA Art. 17
Secure transmissionTLS 1.3 + HSTSGDPR Art. 32, DORA Art. 9