# Payload CMS Authentication Migration Guide ## Overview This guide provides detailed steps for migrating from the current JWT-based authentication system to Payload CMS's built-in authentication system while maintaining backward compatibility and ensuring zero downtime. ## Current Authentication System Analysis ### Existing Implementation - **Technology**: Custom JWT implementation with bcryptjs - **Token Expiry**: 7 days - **Storage**: PostgreSQL (User, AdminUser tables) - **Roles**: USER, ADMIN, SUPER_ADMIN - **Session Management**: Stateless JWT tokens ### Current Auth Flow ```mermaid graph LR A[User Login] --> B[Validate Credentials] B --> C[Generate JWT] C --> D[Return Token] D --> E[Store in LocalStorage] E --> F[Include in Headers] F --> G[Verify on Each Request] ``` ## Payload Authentication System ### Key Features - **Cookie-based sessions** with HTTP-only cookies - **CSRF protection** built-in - **Refresh tokens** for extended sessions - **Password reset flow** with email verification - **Two-factor authentication** support (optional) - **OAuth providers** integration capability ### Payload Auth Flow ```mermaid graph LR A[User Login] --> B[Validate Credentials] B --> C[Create Session] C --> D[Set HTTP-only Cookie] D --> E[Return User Data] E --> F[Auto-include Cookie] F --> G[Session Validation] ``` ## Migration Strategy ### Phase 1: Dual Authentication Support #### Step 1.1: Configure Payload Auth ```typescript // config/auth.config.ts export const authConfig = { // Enable both JWT and session-based auth cookies: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, domain: process.env.COOKIE_DOMAIN, }, tokenExpiration: 604800, // 7 days (matching current) maxLoginAttempts: 5, lockTime: 600000, // 10 minutes // Custom JWT for backward compatibility jwt: { secret: process.env.JWT_SECRET, expiresIn: '7d', }, // Session configuration session: { resave: false, saveUninitialized: false, secret: process.env.SESSION_SECRET, cookie: { maxAge: 604800000, // 7 days in milliseconds }, }, }; ``` #### Step 1.2: Create Compatibility Layer ```typescript // lib/auth/compatibility.ts import jwt from 'jsonwebtoken'; import { PayloadRequest } from 'payload/types'; export class AuthCompatibilityLayer { /** * Validates both old JWT tokens and new Payload sessions */ static async validateRequest(req: PayloadRequest) { // Check for Payload session first if (req.user) { return { valid: true, user: req.user, method: 'payload' }; } // Check for legacy JWT token const token = this.extractJWT(req); if (token) { try { const decoded = jwt.verify(token, process.env.JWT_SECRET!); const user = await this.getUserFromToken(decoded); return { valid: true, user, method: 'jwt' }; } catch (error) { return { valid: false, error: 'Invalid token' }; } } return { valid: false, error: 'No authentication provided' }; } private static extractJWT(req: PayloadRequest): string | null { const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { return authHeader.substring(7); } return null; } private static async getUserFromToken(decoded: any) { // Fetch user from Payload collections const user = await payload.findByID({ collection: 'users', id: decoded.userId, }); return user; } /** * Generates both JWT (for legacy) and creates Payload session */ static async createDualAuth(user: any, req: PayloadRequest) { // Create Payload session const payloadToken = await payload.login({ collection: 'users', data: { email: user.email, password: user.password, }, req, }); // Generate legacy JWT const jwtToken = jwt.sign( { userId: user.id, email: user.email, role: user.role, }, process.env.JWT_SECRET!, { expiresIn: '7d' } ); return { payloadToken, jwtToken, // For backward compatibility user, }; } } ``` ### Phase 2: User Migration #### Step 2.1: User Data Migration Script ```typescript // scripts/migrate-users.ts import { PrismaClient } from '@prisma/client'; import payload from 'payload'; import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); interface MigrationResult { success: number; failed: number; errors: Array<{ email: string; error: string }>; } export async function migrateUsers(): Promise { const result: MigrationResult = { success: 0, failed: 0, errors: [], }; // Fetch all users from Prisma const users = await prisma.user.findMany({ include: { subscription: true, userSettings: true, bookmarks: true, highlights: true, }, }); console.log(`Starting migration of ${users.length} users...`); for (const user of users) { try { // Check if user already exists in Payload const existing = await payload.find({ collection: 'users', where: { email: { equals: user.email }, }, }); if (existing.docs.length > 0) { console.log(`User ${user.email} already migrated, skipping...`); continue; } // Create user in Payload const payloadUser = await payload.create({ collection: 'users', data: { email: user.email, name: user.name, role: user.role, // Password handling - already hashed password: user.password, _verified: true, // Mark as verified // Custom fields stripeCustomerId: user.stripeCustomerId, favoriteVersion: user.favoriteVersion || 'VDC', // Settings profileSettings: { fontSize: user.userSettings?.fontSize || 16, theme: user.userSettings?.theme || 'light', showVerseNumbers: user.userSettings?.showVerseNumbers ?? true, enableNotifications: user.userSettings?.enableNotifications ?? true, }, // Timestamps createdAt: user.createdAt, updatedAt: user.updatedAt, lastLogin: user.lastLogin, }, }); // Migrate related data if (user.subscription) { await migrateUserSubscription(payloadUser.id, user.subscription); } if (user.bookmarks.length > 0) { await migrateUserBookmarks(payloadUser.id, user.bookmarks); } if (user.highlights.length > 0) { await migrateUserHighlights(payloadUser.id, user.highlights); } result.success++; console.log(`✓ Migrated user: ${user.email}`); } catch (error) { result.failed++; result.errors.push({ email: user.email, error: error.message, }); console.error(`✗ Failed to migrate user ${user.email}:`, error); } } return result; } async function migrateUserSubscription(userId: string, subscription: any) { await payload.create({ collection: 'subscriptions', data: { user: userId, stripeSubscriptionId: subscription.stripeSubscriptionId, planName: subscription.planName, status: subscription.status, currentPeriodStart: subscription.currentPeriodStart, currentPeriodEnd: subscription.currentPeriodEnd, conversationCount: subscription.conversationCount, }, }); } async function migrateUserBookmarks(userId: string, bookmarks: any[]) { for (const bookmark of bookmarks) { await payload.create({ collection: 'bookmarks', data: { user: userId, book: bookmark.book, chapter: bookmark.chapter, verse: bookmark.verse, note: bookmark.note, createdAt: bookmark.createdAt, }, }); } } async function migrateUserHighlights(userId: string, highlights: any[]) { for (const highlight of highlights) { await payload.create({ collection: 'highlights', data: { user: userId, verseId: highlight.verseId, color: highlight.color, note: highlight.note, createdAt: highlight.createdAt, }, }); } } ``` #### Step 2.2: Password Migration Strategy Since passwords are already hashed with bcrypt, we have three options: **Option 1: Direct Hash Migration (Recommended)** ```typescript // hooks/auth.hooks.ts export const passwordValidationHook = { beforeOperation: async ({ args, operation }) => { if (operation === 'login') { const { email, password } = args.data; // Find user const user = await payload.find({ collection: 'users', where: { email: { equals: email } }, }); if (user.docs.length === 0) { throw new Error('Invalid credentials'); } const userDoc = user.docs[0]; // Check if password needs rehashing (migrated user) if (userDoc.passwordMigrated) { // Use bcrypt directly for migrated passwords const valid = await bcrypt.compare(password, userDoc.password); if (!valid) { throw new Error('Invalid credentials'); } // Rehash with Payload's method on successful login await payload.update({ collection: 'users', id: userDoc.id, data: { password, // Will be hashed by Payload passwordMigrated: false, }, }); } } }, }; ``` **Option 2: Password Reset Campaign** ```typescript // scripts/password-reset-campaign.ts export async function sendPasswordResetToMigratedUsers() { const migratedUsers = await payload.find({ collection: 'users', where: { passwordMigrated: { equals: true }, }, }); for (const user of migratedUsers.docs) { const token = await payload.forgotPassword({ collection: 'users', data: { email: user.email }, disableEmail: false, }); // Send custom email explaining migration await sendMigrationEmail({ to: user.email, token, userName: user.name, }); } } ``` ### Phase 3: API Endpoint Migration #### Step 3.1: Update Frontend API Calls ```typescript // lib/api/auth.ts (Frontend) export class AuthAPI { private static baseURL = process.env.NEXT_PUBLIC_API_URL; static async login(email: string, password: string) { try { // Try new Payload endpoint first const response = await fetch(`${this.baseURL}/api/users/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', // Important for cookies body: JSON.stringify({ email, password }), }); if (response.ok) { const data = await response.json(); // Store JWT for backward compatibility if provided if (data.token) { localStorage.setItem('token', data.token); } return { success: true, user: data.user }; } } catch (error) { console.error('Payload login failed, trying legacy...', error); } // Fallback to legacy endpoint return this.legacyLogin(email, password); } private static async legacyLogin(email: string, password: string) { const response = await fetch(`${this.baseURL}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await response.json(); if (data.token) { localStorage.setItem('token', data.token); } return data; } static async logout() { // Clear both Payload session and JWT await fetch(`${this.baseURL}/api/users/logout`, { method: 'POST', credentials: 'include', }); localStorage.removeItem('token'); } static async getMe() { // Try Payload endpoint with cookie try { const response = await fetch(`${this.baseURL}/api/users/me`, { credentials: 'include', }); if (response.ok) { return response.json(); } } catch (error) { // Fallback to JWT const token = localStorage.getItem('token'); if (token) { const response = await fetch(`${this.baseURL}/api/auth/me`, { headers: { 'Authorization': `Bearer ${token}`, }, }); return response.json(); } } throw new Error('Not authenticated'); } } ``` #### Step 3.2: Update API Middleware ```typescript // middleware/auth.middleware.ts import { PayloadRequest } from 'payload/types'; import { AuthCompatibilityLayer } from '../lib/auth/compatibility'; export async function authMiddleware(req: PayloadRequest, res: any, next: any) { const auth = await AuthCompatibilityLayer.validateRequest(req); if (!auth.valid) { return res.status(401).json({ error: auth.error }); } // Attach user to request for both auth methods req.user = auth.user; req.authMethod = auth.method; // Track which auth method was used // Log for monitoring during migration console.log(`Auth method: ${auth.method} for user: ${auth.user.email}`); next(); } ``` ### Phase 4: Testing & Validation #### Step 4.1: Authentication Test Suite ```typescript // tests/auth/migration.test.ts import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import payload from 'payload'; import { testUser } from '../fixtures/users'; describe('Authentication Migration Tests', () => { beforeAll(async () => { await payload.init({ local: true, secret: 'test-secret', }); }); describe('Dual Authentication', () => { it('should accept legacy JWT tokens', async () => { const token = generateLegacyJWT(testUser); const response = await fetch('/api/protected', { headers: { 'Authorization': `Bearer ${token}`, }, }); expect(response.status).toBe(200); }); it('should accept Payload session cookies', async () => { const loginResponse = await fetch('/api/users/login', { method: 'POST', body: JSON.stringify({ email: testUser.email, password: testUser.password, }), credentials: 'include', }); const cookie = loginResponse.headers.get('set-cookie'); const response = await fetch('/api/protected', { headers: { 'Cookie': cookie, }, }); expect(response.status).toBe(200); }); it('should migrate password on first login', async () => { const migratedUser = await createMigratedUser(); const response = await fetch('/api/users/login', { method: 'POST', body: JSON.stringify({ email: migratedUser.email, password: 'original-password', }), }); expect(response.status).toBe(200); // Check that password was rehashed const user = await payload.findByID({ collection: 'users', id: migratedUser.id, }); expect(user.passwordMigrated).toBe(false); }); }); describe('Session Management', () => { it('should maintain session across requests', async () => { const session = await createAuthSession(testUser); // Make multiple requests with same session for (let i = 0; i < 5; i++) { const response = await fetch('/api/protected', { headers: { 'Cookie': session.cookie, }, }); expect(response.status).toBe(200); } }); it('should refresh token before expiry', async () => { const session = await createAuthSession(testUser); // Fast-forward time to near expiry jest.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); // 6 days const response = await fetch('/api/users/refresh', { headers: { 'Cookie': session.cookie, }, }); expect(response.status).toBe(200); const newCookie = response.headers.get('set-cookie'); expect(newCookie).toBeTruthy(); }); }); describe('Role-Based Access', () => { it('should enforce admin access', async () => { const regularUser = await createUser({ role: 'USER' }); const adminUser = await createUser({ role: 'ADMIN' }); const regularSession = await createAuthSession(regularUser); const adminSession = await createAuthSession(adminUser); // Regular user should be denied const regularResponse = await fetch('/api/admin/users', { headers: { 'Cookie': regularSession.cookie }, }); expect(regularResponse.status).toBe(403); // Admin should be allowed const adminResponse = await fetch('/api/admin/users', { headers: { 'Cookie': adminSession.cookie }, }); expect(adminResponse.status).toBe(200); }); }); }); ``` #### Step 4.2: Migration Validation Script ```typescript // scripts/validate-migration.ts export async function validateMigration() { const report = { users: { total: 0, migrated: 0, failed: [] }, auth: { jwt: 0, payload: 0, dual: 0 }, subscriptions: { total: 0, active: 0, cancelled: 0 }, errors: [], }; // Check user migration const prismaUsers = await prisma.user.count(); const payloadUsers = await payload.count({ collection: 'users' }); report.users.total = prismaUsers; report.users.migrated = payloadUsers.totalDocs; // Test authentication methods const testResults = await testAuthenticationMethods(); report.auth = testResults; // Validate subscriptions const subscriptions = await validateSubscriptions(); report.subscriptions = subscriptions; // Generate report console.log('Migration Validation Report:'); console.log('============================'); console.log(`Users: ${report.users.migrated}/${report.users.total} migrated`); console.log(`Auth Methods: JWT: ${report.auth.jwt}, Payload: ${report.auth.payload}`); console.log(`Subscriptions: ${report.subscriptions.active} active`); if (report.errors.length > 0) { console.log('\nErrors found:'); report.errors.forEach(error => console.error(error)); } return report; } ``` ### Phase 5: Gradual Rollout #### Step 5.1: Feature Flags ```typescript // lib/features/flags.ts export const AuthFeatureFlags = { USE_PAYLOAD_AUTH: process.env.NEXT_PUBLIC_USE_PAYLOAD_AUTH === 'true', DUAL_AUTH_MODE: process.env.NEXT_PUBLIC_DUAL_AUTH === 'true', FORCE_PASSWORD_RESET: process.env.FORCE_PASSWORD_RESET === 'true', }; // Usage in components export function LoginForm() { const handleSubmit = async (data: LoginData) => { if (AuthFeatureFlags.USE_PAYLOAD_AUTH) { return payloadLogin(data); } else if (AuthFeatureFlags.DUAL_AUTH_MODE) { return dualLogin(data); } else { return legacyLogin(data); } }; } ``` #### Step 5.2: A/B Testing ```typescript // lib/ab-testing/auth.ts export function getAuthStrategy(userId?: string): 'legacy' | 'payload' | 'dual' { // Percentage-based rollout const rolloutPercentage = parseInt(process.env.PAYLOAD_AUTH_ROLLOUT || '0'); if (!userId) { // New users always get Payload auth return 'payload'; } // Consistent assignment based on user ID const hash = hashUserId(userId); const bucket = hash % 100; if (bucket < rolloutPercentage) { return 'payload'; } else if (process.env.DUAL_AUTH === 'true') { return 'dual'; } else { return 'legacy'; } } ``` ### Phase 6: Monitoring & Observability #### Step 6.1: Authentication Metrics ```typescript // lib/monitoring/auth-metrics.ts import { metrics } from '@opentelemetry/api-metrics'; export class AuthMetrics { private meter = metrics.getMeter('auth-migration'); private loginCounter = this.meter.createCounter('auth_login_total'); private methodHistogram = this.meter.createHistogram('auth_method_duration'); private failureCounter = this.meter.createCounter('auth_failure_total'); trackLogin(method: 'jwt' | 'payload' | 'dual', success: boolean, duration: number) { this.loginCounter.add(1, { method, success: success.toString(), }); this.methodHistogram.record(duration, { method, }); if (!success) { this.failureCounter.add(1, { method }); } } async generateReport() { return { totalLogins: await this.getTotalLogins(), methodDistribution: await this.getMethodDistribution(), failureRate: await this.getFailureRate(), avgDuration: await this.getAverageDuration(), }; } } ``` #### Step 6.2: Monitoring Dashboard ```typescript // components/admin/AuthMigrationDashboard.tsx export function AuthMigrationDashboard() { const [metrics, setMetrics] = useState(); useEffect(() => { const fetchMetrics = async () => { const data = await fetch('/api/admin/auth-metrics').then(r => r.json()); setMetrics(data); }; fetchMetrics(); const interval = setInterval(fetchMetrics, 30000); // Update every 30s return () => clearInterval(interval); }, []); return (

Authentication Migration Status

} />

Recent Authentication Issues

); } ``` ## Rollback Procedures ### Emergency Rollback Script ```typescript // scripts/auth-rollback.ts export async function rollbackAuth() { console.log('Starting authentication rollback...'); // 1. Disable Payload auth endpoints await updateEnvironmentVariable('USE_PAYLOAD_AUTH', 'false'); // 2. Re-enable legacy endpoints await updateEnvironmentVariable('USE_LEGACY_AUTH', 'true'); // 3. Clear Payload sessions await payload.delete({ collection: 'sessions', where: {}, }); // 4. Notify users await sendSystemNotification({ message: 'Authentication system maintenance in progress', type: 'warning', }); // 5. Monitor legacy auth performance startLegacyAuthMonitoring(); console.log('Rollback complete. Legacy auth restored.'); } ``` ## Best Practices & Recommendations ### Security Considerations 1. **Never log passwords** in any form 2. **Use HTTPS only** for production 3. **Implement rate limiting** on auth endpoints 4. **Monitor failed login attempts** 5. **Regular security audits** of auth flows ### Performance Optimization 1. **Cache user sessions** in Redis 2. **Implement session pooling** 3. **Use database indexes** on email fields 4. **Lazy-load user relationships** 5. **CDN for static auth assets** ### User Experience 1. **Transparent migration** - users shouldn't notice 2. **Clear error messages** for auth failures 3. **Password strength indicators** 4. **Remember me functionality** 5. **Social login options** (future enhancement) ## Conclusion The migration to Payload CMS authentication provides: - **Enhanced security** with HTTP-only cookies and CSRF protection - **Better session management** with automatic refresh - **Simplified codebase** with less custom auth code - **Future-proof architecture** for OAuth and 2FA The dual-authentication approach ensures zero downtime and allows for gradual migration with full rollback capability. --- *Document Version: 1.0* *Last Updated: November 2024* *Author: Biblical Guide Development Team*