Skip to main content
PensionsPortal.ie uses Auth.js v5 (formerly NextAuth) with the Credentials provider. Users authenticate with email and bcrypt-hashed passwords stored in Neon PostgreSQL. Sessions are issued as signed JWTs, carrying role and tenancy claims.

Architecture

Browser → POST /api/auth/signin
         → Credentials provider
         → DB lookup (Drizzle ORM)
         → bcrypt.compare()
         → JWT issued (role, brokerId, employerId)
         → HttpOnly session cookie

Configuration (src/lib/auth.ts)

The Auth.js configuration lives in src/lib/auth.ts and exports { handlers, auth, signIn, signOut }.

Session Strategy

session: {
  strategy: "jwt",
}
JWT sessions are used. No database session table is required, reducing attack surface and eliminating session fixation risks tied to server-side session stores.

Custom JWT Claims

The jwt and session callbacks propagate three pension-specific fields from the user record into every token and session:
ClaimTypePurpose
roleUserRoleDrives RBAC throughout the application
brokerIdstring | nullScopes data queries to the correct broker
employerIdstring | nullScopes data queries to the correct employer
async jwt({ token, user }) {
  if (user) {
    token.id = user.id
    token.role = user.role
    token.brokerId = user.brokerId
    token.employerId = user.employerId
  }
  return token
}

Password Hashing

Passwords are hashed with bcryptjs before storage. On authentication, bcrypt.compare() is used — timing-safe by design. Plaintext passwords are never logged, stored, or transmitted.

Login Page

Auth.js is configured to redirect unauthenticated requests to /auth/login:
pages: {
  signIn: "/auth/login",
}

Auth Flow

1

Credential submission

User submits email + password to POST /api/auth/signin (handled by Auth.js route handler at src/app/api/auth/[...nextauth]/route.ts).
2

Database lookup

Drizzle ORM queries the users table by email. If DATABASE_URL is not set, the request fails in production.
3

Password verification

bcrypt.compare(providedPassword, storedHash) — returns false if the hash does not match. Wrong passwords do not fall through to any fallback.
4

JWT issuance

On success, Auth.js issues a signed JWT containing id, role, brokerId, and employerId. The JWT secret is AUTH_SECRET (required environment variable).
5

Session cookie

The JWT is stored in an HttpOnly, Secure, SameSite=Lax cookie. It is not accessible to JavaScript.

Role Mapping

The database schema uses granular role names; Auth.js maps these to application-level UserRole values:
Schema RoleApplication Role
SuperAdminadmin
BrokerAdminbroker
BrokerUserbroker
Trusteeemployer

Development vs Production

Auth.js includes a dev-only credential fallback (hardcoded test users). This fallback is explicitly disabled in production via a NODE_ENV === "production" guard. Never deploy without DATABASE_URL set in production.
In production:
  • DATABASE_URL must be set — the DB fallback is the only auth path
  • AUTH_SECRET must be a cryptographically random string (minimum 32 bytes)
  • The dev credential fallback returns null immediately

Environment Variables

VariableRequiredDescription
AUTH_SECRETSigns and verifies JWTs. Generate with openssl rand -hex 32.
AUTH_URL✅ ProductionCanonical URL for Auth.js callbacks
DATABASE_URL✅ ProductionNeon PostgreSQL connection string

TypeScript Augmentation

Custom session fields are typed via module augmentation in src/lib/auth-types.ts, ensuring TypeScript catches any access to undefined session properties at compile time.
declare module "next-auth" {
  interface Session {
    user: PensionsUser & DefaultSession["user"]
  }
}