Files
biblical-guide.com/PAYLOAD_AUTH_MIGRATION_GUIDE.md
Andrei 9b5c0ed8bb build: production build with Phase 1 2025 Bible Reader implementation complete
Includes all Phase 1 features:
- Search-first navigation with auto-complete
- Responsive reading interface (desktop/tablet/mobile)
- 4 customization presets + full fine-tuning controls
- Layered details panel with notes, bookmarks, highlights
- Smart offline caching with IndexedDB and auto-sync
- Full accessibility (WCAG 2.1 AA)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 20:38:01 +00:00

24 KiB

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

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

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

// 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

// 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

// 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<MigrationResult> {
  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)

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// components/admin/AuthMigrationDashboard.tsx
export function AuthMigrationDashboard() {
  const [metrics, setMetrics] = useState<AuthMetrics>();

  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 (
    <div className="dashboard">
      <h2>Authentication Migration Status</h2>

      <div className="metrics-grid">
        <MetricCard
          title="Auth Method Distribution"
          value={
            <PieChart data={[
              { name: 'JWT', value: metrics?.jwt || 0 },
              { name: 'Payload', value: metrics?.payload || 0 },
              { name: 'Dual', value: metrics?.dual || 0 },
            ]} />
          }
        />

        <MetricCard
          title="Migration Progress"
          value={`${metrics?.migratedUsers || 0} / ${metrics?.totalUsers || 0}`}
          subtitle={`${Math.round((metrics?.migratedUsers / metrics?.totalUsers) * 100)}% complete`}
        />

        <MetricCard
          title="Auth Success Rate"
          value={`${metrics?.successRate || 0}%`}
          trend={metrics?.successTrend}
        />

        <MetricCard
          title="Active Sessions"
          value={metrics?.activeSessions || 0}
          subtitle={`JWT: ${metrics?.jwtSessions}, Payload: ${metrics?.payloadSessions}`}
        />
      </div>

      <div className="recent-issues">
        <h3>Recent Authentication Issues</h3>
        <IssuesList issues={metrics?.recentIssues || []} />
      </div>
    </div>
  );
}

Rollback Procedures

Emergency Rollback Script

// 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