Handle Supabase Auth Errors in Next.js Middleware
Auth errors crashing your Next.js middleware? Learn how to handle Supabase auth errors gracefully with proper error handling patterns.
Handle Supabase Auth Errors in Next.js Middleware#
Authentication errors in Next.js middleware can crash your entire application if not handled properly. When Supabase auth fails in middleware, it can prevent users from accessing any page—even public ones. As covered in our Supabase Authentication & Authorization Patterns, proper error handling is essential for robust authentication systems.
In this guide, I'll show you how to handle Supabase auth errors gracefully in Next.js 14 middleware, ensuring your app stays resilient even when authentication fails.
Why Auth Errors Break Middleware#
Next.js middleware runs on every request before the page loads. When Supabase auth throws an error in middleware, it can:
- Block all routes - Including public pages that don't need authentication
- Cause infinite redirects - Error → Login → Error → Login loop
- Expose sensitive errors - Stack traces visible to users
- Break the user experience - White screen or 500 errors
Common Auth Errors in Middleware#
// ❌ Common errors that crash middleware:
// 1. Network timeout
Error: fetch failed - timeout of 5000ms exceeded
// 2. Invalid JWT token
Error: JWT expired
// 3. Supabase service unavailable
Error: Failed to fetch
// 4. Cookie parsing errors
Error: Invalid cookie format
Step-by-Step Solution#
Step 1: Create Error-Safe Middleware#
Wrap all auth operations in try-catch blocks:
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
try {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// ✅ CORRECT: Wrap auth check in try-catch
const { data: { user }, error } = await supabase.auth.getUser()
// Handle auth errors gracefully
if (error) {
console.error('Auth error in middleware:', error.message)
// If trying to access protected route, redirect to login
if (request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login?error=auth_failed', request.url))
}
// For public routes, continue without auth
return response
}
// Redirect authenticated users away from auth pages
if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// Redirect unauthenticated users from protected routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
} catch (error) {
// ✅ CORRECT: Catch any unexpected errors
console.error('Unexpected error in middleware:', error)
// Allow public routes to continue
if (!request.nextUrl.pathname.startsWith('/dashboard')) {
return response
}
// Redirect to login for protected routes
return NextResponse.redirect(new URL('/login?error=unexpected', request.url))
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Step 2: Handle Specific Error Types#
Create a helper function to categorize errors:
// lib/auth-errors.ts
export type AuthErrorType =
| 'network'
| 'expired'
| 'invalid'
| 'unauthorized'
| 'unknown'
export function categorizeAuthError(error: any): AuthErrorType {
const message = error?.message?.toLowerCase() || ''
if (message.includes('fetch failed') || message.includes('timeout')) {
return 'network'
}
if (message.includes('jwt') && message.includes('expired')) {
return 'expired'
}
if (message.includes('invalid') || message.includes('malformed')) {
return 'invalid'
}
if (message.includes('unauthorized') || message.includes('forbidden')) {
return 'unauthorized'
}
return 'unknown'
}
export function getErrorRedirect(errorType: AuthErrorType, requestUrl: string): string {
const baseUrl = new URL(requestUrl).origin
switch (errorType) {
case 'network':
return `${baseUrl}/login?error=network_error`
case 'expired':
return `${baseUrl}/login?error=session_expired`
case 'invalid':
return `${baseUrl}/login?error=invalid_session`
case 'unauthorized':
return `${baseUrl}/login?error=unauthorized`
default:
return `${baseUrl}/login?error=auth_failed`
}
}
Step 3: Implement Error-Aware Middleware#
Use the error categorization in middleware:
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { categorizeAuthError, getErrorRedirect } from '@/lib/auth-errors'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
try {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value: '', ...options })
},
},
}
)
const { data: { user }, error } = await supabase.auth.getUser()
if (error) {
const errorType = categorizeAuthError(error)
console.error(`Auth error (${errorType}):`, error.message)
// Only redirect protected routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
const redirectUrl = getErrorRedirect(errorType, request.url)
return NextResponse.redirect(redirectUrl)
}
// Allow public routes to continue
return response
}
// Handle authenticated users
if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// Handle unauthenticated users
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
} catch (error) {
console.error('Unexpected middleware error:', error)
// Fail open for public routes
if (!request.nextUrl.pathname.startsWith('/dashboard')) {
return response
}
// Fail closed for protected routes
return NextResponse.redirect(new URL('/login?error=unexpected', request.url))
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Step 4: Display Error Messages to Users#
Create a login page that shows error messages:
// app/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'
const ERROR_MESSAGES = {
network_error: 'Network error. Please check your connection and try again.',
session_expired: 'Your session has expired. Please sign in again.',
invalid_session: 'Invalid session. Please sign in again.',
unauthorized: 'Unauthorized access. Please sign in.',
auth_failed: 'Authentication failed. Please try again.',
unexpected: 'An unexpected error occurred. Please try again.',
}
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const searchParams = useSearchParams()
const supabase = createClient()
useEffect(() => {
const errorCode = searchParams.get('error')
if (errorCode && errorCode in ERROR_MESSAGES) {
setError(ERROR_MESSAGES[errorCode as keyof typeof ERROR_MESSAGES])
}
}, [searchParams])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const { data, error: loginError } = await supabase.auth.signInWithPassword({
email,
password,
})
if (loginError) {
setError(loginError.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div>
<h1>Sign In</h1>
{error && (
<div style={{
padding: '12px',
backgroundColor: '#fee',
border: '1px solid #fcc',
borderRadius: '4px',
marginBottom: '16px'
}}>
{error}
</div>
)}
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
)
}
Step 5: Add Retry Logic for Network Errors#
Implement automatic retry for transient errors:
// lib/auth-retry.ts
export async function retryAuthOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: any
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation()
} catch (error: any) {
lastError = error
// Don't retry on auth errors, only network errors
if (!error.message?.includes('fetch failed') &&
!error.message?.includes('timeout')) {
throw error
}
// Wait before retrying
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs * (attempt + 1)))
}
}
}
throw lastError
}
Use retry logic in middleware:
// middleware.ts (updated)
import { retryAuthOperation } from '@/lib/auth-retry'
export async function middleware(request: NextRequest) {
// ... setup code ...
try {
// ✅ CORRECT: Retry network errors
const { data: { user }, error } = await retryAuthOperation(
() => supabase.auth.getUser(),
2, // Max 2 retries
500 // 500ms delay
)
// ... rest of middleware logic ...
} catch (error) {
// ... error handling ...
}
}
Working Code Examples#
Complete Error-Resilient Middleware#
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
// Skip middleware for static files and API routes
if (
request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname.startsWith('/api') ||
request.nextUrl.pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/)
) {
return response
}
try {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// Get user with timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Auth timeout')), 5000)
)
const authPromise = supabase.auth.getUser()
const { data: { user }, error } = await Promise.race([
authPromise,
timeoutPromise
]) as any
// Handle auth errors
if (error) {
console.error('Auth error:', error.message)
// Protected routes require auth
if (request.nextUrl.pathname.startsWith('/dashboard')) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('error', 'auth_failed')
loginUrl.searchParams.set('from', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
// Public routes continue
return response
}
// Redirect logic
const isAuthPage = request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/signup'
const isProtectedPage = request.nextUrl.pathname.startsWith('/dashboard')
if (user && isAuthPage) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
if (!user && isProtectedPage) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
return response
} catch (error: any) {
console.error('Middleware error:', error.message)
// Fail open for public routes
if (!request.nextUrl.pathname.startsWith('/dashboard')) {
return response
}
// Fail closed for protected routes
return NextResponse.redirect(new URL('/login?error=unexpected', request.url))
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Common Mistakes#
-
Mistake #1: Not wrapping auth calls in try-catch - Any unhandled error in middleware crashes the entire app. Always use try-catch blocks around auth operations.
-
Mistake #2: Blocking public routes on auth errors - If auth fails, public pages should still be accessible. Only redirect protected routes to login.
-
Mistake #3: Not setting timeouts - Supabase auth calls can hang indefinitely. Always set a timeout (5 seconds recommended).
-
Mistake #4: Exposing error details to users - Never show raw error messages or stack traces. Use friendly, generic error messages instead.
-
Mistake #5: Not logging errors - Always log auth errors for debugging, but ensure logs don't contain sensitive information like tokens.
FAQ#
What happens if Supabase is down?#
If Supabase is completely unavailable, your middleware should fail gracefully. Public routes should remain accessible, while protected routes redirect to login with an error message. Implement retry logic for transient failures.
Should I retry all auth errors?#
No, only retry network-related errors (timeouts, connection failures). Don't retry authentication errors like expired tokens or invalid credentials—these require user action.
How do I test error handling in middleware?#
Temporarily modify your middleware to throw errors, or use network throttling in Chrome DevTools to simulate slow/failed requests. Test both protected and public routes.
Can I use middleware for API routes?#
Yes, but be careful. API routes should return JSON errors, not redirect to login pages. Consider separate error handling for API routes vs page routes.
How do I handle errors in Server Components?#
Server Components should also wrap auth calls in try-catch. Unlike middleware, Server Components can show error UI directly using error boundaries.
Related Articles#
- Fix Supabase Auth Session Not Persisting After Refresh Next.js 14
- Supabase Auth Redirect Not Working Next.js App Router Solution
- Implement Supabase Magic Link Auth in Next.js 15
- Build Protected Routes in Next.js 15 with Supabase
Conclusion#
Handling auth errors in Next.js middleware requires defensive programming: wrap all auth operations in try-catch, categorize errors appropriately, and fail gracefully. The key principles are:
- Always use try-catch around auth operations
- Fail open for public routes, fail closed for protected routes
- Set timeouts to prevent hanging requests
- Show user-friendly error messages
- Log errors for debugging without exposing sensitive data
With proper error handling, your authentication system will be resilient to network issues, Supabase outages, and unexpected errors. Users will experience graceful degradation instead of crashes, maintaining trust in your application even when things go wrong.
Test your error handling thoroughly by simulating various failure scenarios, and monitor your logs to catch issues before users report them.
Advanced Error Handling Patterns#
Retry Logic for Transient Errors#
// lib/retry-auth.ts
export async function retryAuthCheck(
supabase: any,
maxRetries: number = 3,
delayMs: number = 100
) {
let lastError: any
for (let i = 0; i < maxRetries; i++) {
try {
const { data: { user }, error } = await supabase.auth.getUser()
if (!error) {
return { user, error: null }
}
lastError = error
// Only retry network errors
if (!isNetworkError(error)) {
return { user: null, error }
}
// Wait before retrying
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, i)))
}
} catch (error) {
lastError = error
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, i)))
}
}
}
return { user: null, error: lastError }
}
function isNetworkError(error: any): boolean {
const message = error?.message?.toLowerCase() || ''
return (
message.includes('fetch failed') ||
message.includes('timeout') ||
message.includes('network') ||
message.includes('econnrefused')
)
}
Use Retry in Middleware#
// middleware.ts
import { retryAuthCheck } from '@/lib/retry-auth'
export async function middleware(request: NextRequest) {
try {
const supabase = createServerClient(...)
// ✅ Use retry logic for auth check
const { user, error } = await retryAuthCheck(supabase, 3, 100)
if (error) {
const errorType = categorizeAuthError(error)
// Network errors after retries - allow access to public routes
if (errorType === 'network') {
if (!request.nextUrl.pathname.startsWith('/dashboard')) {
return response
}
return NextResponse.redirect(
new URL('/login?error=service_unavailable', request.url)
)
}
// Other errors - redirect to login
if (request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(
new URL(getErrorRedirect(errorType, request.url), request.url)
)
}
}
return response
} catch (error) {
console.error('Middleware error:', error)
return response
}
}
Error Recovery Strategies#
Graceful Degradation#
// middleware.ts
export async function middleware(request: NextRequest) {
try {
const supabase = createServerClient(...)
const { data: { user }, error } = await supabase.auth.getUser()
if (error) {
// For public routes, continue without auth
if (isPublicRoute(request.nextUrl.pathname)) {
return response
}
// For protected routes, redirect to login
return NextResponse.redirect(new URL('/login', request.url))
}
return response
} catch (error) {
// If auth completely fails, allow public routes
if (isPublicRoute(request.nextUrl.pathname)) {
return response
}
// Redirect protected routes to login
return NextResponse.redirect(new URL('/login', request.url))
}
}
function isPublicRoute(pathname: string): boolean {
const publicRoutes = ['/', '/login', '/signup', '/about', '/pricing']
return publicRoutes.includes(pathname) || pathname.startsWith('/blog')
}
Fallback Authentication#
// middleware.ts
export async function middleware(request: NextRequest) {
try {
const supabase = createServerClient(...)
const { data: { user }, error } = await supabase.auth.getUser()
if (error) {
// Check for local session backup
const localSession = request.cookies.get('local_session')?.value
if (localSession) {
try {
const session = JSON.parse(localSession)
// Use local session if recent
if (Date.now() - session.timestamp < 3600000) { // 1 hour
return response
}
} catch (e) {
// Invalid local session
}
}
// No fallback available
if (request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return response
} catch (error) {
console.error('Middleware error:', error)
return response
}
}
Testing Error Handling#
Unit Tests for Error Handling#
// __tests__/middleware.test.ts
import { middleware } from '@/middleware'
import { NextRequest } from 'next/server'
describe('Middleware Error Handling', () => {
it('should handle network errors gracefully', async () => {
const request = new NextRequest(new URL('http://localhost:3000/dashboard'))
// Mock Supabase to throw network error
jest.mock('@supabase/ssr', () => ({
createServerClient: () => ({
auth: {
getUser: () => {
throw new Error('fetch failed')
}
}
})
}))
const response = await middleware(request)
expect(response.status).toBe(307) // Redirect
expect(response.headers.get('location')).toContain('/login')
})
it('should allow public routes on auth errors', async () => {
const request = new NextRequest(new URL('http://localhost:3000/'))
// Mock Supabase to throw error
jest.mock('@supabase/ssr', () => ({
createServerClient: () => ({
auth: {
getUser: () => {
throw new Error('auth failed')
}
}
})
}))
const response = await middleware(request)
expect(response.status).toBe(200) // Continue
})
it('should redirect expired sessions', async () => {
const request = new NextRequest(new URL('http://localhost:3000/dashboard'))
// Mock expired JWT
jest.mock('@supabase/ssr', () => ({
createServerClient: () => ({
auth: {
getUser: () => ({
data: { user: null },
error: new Error('JWT expired')
})
}
})
}))
const response = await middleware(request)
expect(response.headers.get('location')).toContain('session_expired')
})
})
Manual Testing Checklist#
□ Test with Supabase service down
□ Test with network throttling (slow 3G)
□ Test with network offline
□ Test with expired JWT token
□ Test with invalid cookie
□ Test public routes with auth errors
□ Test protected routes with auth errors
□ Test rapid requests (race conditions)
□ Test with different browsers
□ Test on mobile devices
Monitoring and Logging#
Add Error Monitoring#
// lib/error-monitoring.ts
export async function logAuthError(
error: any,
context: {
pathname: string
method: string
timestamp: Date
}
) {
const errorData = {
message: error?.message,
code: error?.code,
status: error?.status,
context,
timestamp: context.timestamp.toISOString(),
}
// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.error('Auth Error:', errorData)
}
// Send to monitoring service
if (process.env.NEXT_PUBLIC_ERROR_TRACKING_URL) {
try {
await fetch(process.env.NEXT_PUBLIC_ERROR_TRACKING_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData),
})
} catch (e) {
// Silently fail if monitoring is unavailable
}
}
}
Use in Middleware#
// middleware.ts
import { logAuthError } from '@/lib/error-monitoring'
export async function middleware(request: NextRequest) {
try {
const supabase = createServerClient(...)
const { data: { user }, error } = await supabase.auth.getUser()
if (error) {
await logAuthError(error, {
pathname: request.nextUrl.pathname,
method: request.method,
timestamp: new Date(),
})
// Handle error...
}
return response
} catch (error) {
await logAuthError(error, {
pathname: request.nextUrl.pathname,
method: request.method,
timestamp: new Date(),
})
return response
}
}
Common Mistakes#
-
Mistake #1: Not catching errors in middleware - Unhandled errors crash the entire app
-
Mistake #2: Retrying all errors - Only retry network errors, not auth errors
-
Mistake #3: Blocking public routes on auth errors - Public routes should work even if auth fails
-
Mistake #4: Exposing error details to users - Log errors server-side, show generic messages to users
-
Mistake #5: Not testing error scenarios - Test with Supabase down, network throttled, etc.
FAQ#
What happens if Supabase is down?#
If Supabase is completely unavailable, your middleware should fail gracefully. Public routes should remain accessible, while protected routes redirect to login with an error message. Implement retry logic for transient failures.
Should I retry all auth errors?#
No, only retry network-related errors (timeouts, connection failures). Don't retry authentication errors like expired tokens or invalid credentials—these require user action.
How do I test error handling in middleware?#
Temporarily modify your middleware to throw errors, or use network throttling in Chrome DevTools to simulate slow/failed requests. Test both protected and public routes.
Related Articles#
- Supabase Auth Redirect Not Working Next.js App Router
- Fix Supabase Auth Session Not Persisting After Refresh
- Supabase Authentication & Authorization Patterns
- Building SaaS with Next.js and Supabase
- Deploying Next.js + Supabase to Production
Conclusion#
Handling Supabase auth errors in Next.js middleware requires careful error handling, retry logic, and graceful degradation. The key is ensuring that:
- Public routes remain accessible even if auth fails
- Protected routes redirect to login on auth errors
- Network errors are retried, but auth errors are not
- Errors are logged for monitoring and debugging
With these patterns in place, your Next.js application will be resilient to authentication failures and provide a better user experience even when things go wrong.
Frequently Asked Questions
Continue Reading
Build a Full-Stack App with Next.js and Supabase in 20 Minutes
Build a complete full-stack application with Next.js 15 and Supabase from scratch. Authentication, database, CRUD operations, and deployment — all in 20 minutes.
Fix Supabase Auth Session Not Persisting After Refresh
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Supabase Auth Redirect Not Working Next.js App Router
Auth redirects failing in Next.js App Router? Learn the exact cause and fix it with this complete guide including OAuth and magic link redirects.