Created
November 3, 2025 03:27
-
-
Save elliott-w/f5da3ee6127da7fbae532520d20ccf7d to your computer and use it in GitHub Desktop.
Middleware to Force Payload Login
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { env } from '@env' | |
| import { jwtVerify } from 'jose' | |
| import { NextRequest, NextResponse } from 'next/server' | |
| export async function middleware(request: NextRequest) { | |
| const { pathname } = request.nextUrl | |
| // Skip middleware for static files and assets | |
| if (isStaticFile(pathname)) { | |
| return NextResponse.next() | |
| } | |
| if (env.APP_ENV === 'staging') { | |
| if (await shouldRedirectToAdmin(request)) { | |
| const params = new URLSearchParams() | |
| params.set('redirect', request.nextUrl.pathname) | |
| const adminUrl = new URL(`/admin/login?${params.toString()}`, request.url) | |
| return NextResponse.redirect(adminUrl) | |
| } | |
| } | |
| return NextResponse.next() | |
| } | |
| export const config = { | |
| matcher: [ | |
| /* | |
| * Match all request paths except for the ones starting with: | |
| * - _next/static (static files) | |
| * - _next/image (image optimization files) | |
| * - admin (Payload admin panel) | |
| */ | |
| '/((?!api|_next/static|_next/image|admin).*)', | |
| ], | |
| } | |
| /** | |
| * Most performant file detection - uses a single regex pattern | |
| * Pre-compiled regex is faster than array iterations or multiple checks | |
| */ | |
| const STATIC_FILE_REGEX = | |
| /\.(js|css|png|jpe?g|gif|svg|ico|woff2?|ttf|eot|mp4|webm|pdf|zip|json|xml|txt)$/i | |
| function isStaticFile(pathname: string): boolean { | |
| return STATIC_FILE_REGEX.test(pathname) | |
| } | |
| const shouldRedirectToAdmin = async ( | |
| request: NextRequest | |
| ): Promise<boolean> => { | |
| if (process.env.NODE_ENV === 'production') { | |
| const payloadSecret = process.env.PAYLOAD_SECRET! | |
| // Check if user is authenticated | |
| const { isAuthenticated } = await isUserAuthenticated(request, { | |
| payloadSecret, | |
| cookieName: 'payload-token', // Default Payload cookie name | |
| requiredCollection: 'users', // Optional: require specific collection | |
| }) | |
| return !isAuthenticated | |
| } | |
| return false | |
| } | |
| // Types for Payload JWT payload | |
| interface PayloadJWTPayload { | |
| id: string | |
| email: string | |
| collection: string | |
| iat: number | |
| exp: number | |
| [key: string]: any // For additional fields saved to JWT via saveToJWT | |
| } | |
| /** | |
| * Creates the hashed secret that Payload uses internally for JWT signing | |
| * Payload hashes the secret with SHA-256 and takes the first 32 characters | |
| */ | |
| async function createPayloadSecret(originalSecret: string): Promise<string> { | |
| const encoder = new TextEncoder() | |
| const data = encoder.encode(originalSecret) | |
| const hashBuffer = await crypto.subtle.digest('SHA-256', data) | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)) | |
| const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') | |
| return hashHex.slice(0, 32) | |
| } | |
| /** | |
| * Lightweight JWT verification for Payload tokens without initializing Payload | |
| * Returns the decoded user data or null if invalid/expired | |
| */ | |
| async function verifyPayloadJWT( | |
| token: string, | |
| payloadSecret: string | |
| ): Promise<PayloadJWTPayload | null> { | |
| try { | |
| // Hash the secret the same way Payload does internally | |
| const hashedSecret = await createPayloadSecret(payloadSecret) | |
| // Convert to Uint8Array for jose library | |
| const secretKey = new TextEncoder().encode(hashedSecret) | |
| // Verify and decode the JWT | |
| const { payload } = await jwtVerify(token, secretKey, { | |
| algorithms: ['HS256'], // Payload uses HS256 by default | |
| }) | |
| return payload as PayloadJWTPayload | |
| } catch (error) { | |
| // Token is invalid, expired, or malformed | |
| console.error('JWT verification failed:', error) | |
| return null | |
| } | |
| } | |
| /** | |
| * Extract JWT token from various sources (cookie, Authorization header) | |
| */ | |
| function extractToken( | |
| request: NextRequest, | |
| cookieName: string = 'payload-token' | |
| ): string | null { | |
| // Try to get from cookie first (most common for Payload) | |
| const cookieToken = request.cookies.get(cookieName)?.value | |
| if (cookieToken) { | |
| return cookieToken | |
| } | |
| // Try Authorization header as fallback | |
| const authHeader = request.headers.get('authorization') | |
| if (authHeader?.startsWith('Bearer ')) { | |
| return authHeader.substring(7) | |
| } | |
| return null | |
| } | |
| /** | |
| * Check if user is authenticated with optional role/permission checks | |
| */ | |
| async function isUserAuthenticated( | |
| request: NextRequest, | |
| options: { | |
| payloadSecret: string | |
| cookieName?: string | |
| requiredCollection?: string | |
| } | |
| ): Promise<{ isAuthenticated: boolean; user: PayloadJWTPayload | null }> { | |
| const { | |
| payloadSecret, | |
| cookieName = 'payload-token', | |
| requiredCollection, | |
| } = options | |
| // Extract token | |
| const token = extractToken(request, cookieName) | |
| if (!token) { | |
| return { isAuthenticated: false, user: null } | |
| } | |
| // Verify token | |
| const user = await verifyPayloadJWT(token, payloadSecret) | |
| if (!user) { | |
| return { isAuthenticated: false, user: null } | |
| } | |
| // Check collection requirement | |
| if (requiredCollection && user.collection !== requiredCollection) { | |
| return { isAuthenticated: false, user: null } | |
| } | |
| return { isAuthenticated: true, user } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment