Handle Supabase Auth Errors in Next.js Middleware
technology

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.

2026-02-16
10 min read
Handle Supabase Auth Errors in Next.js Middleware

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:

  1. Block all routes - Including public pages that don't need authentication
  2. Cause infinite redirects - Error → Login → Error → Login loop
  3. Expose sensitive errors - Stack traces visible to users
  4. 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.

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:

  1. Always use try-catch around auth operations
  2. Fail open for public routes, fail closed for protected routes
  3. Set timeouts to prevent hanging requests
  4. Show user-friendly error messages
  5. 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.

Frequently Asked Questions

|

Have more questions? Contact us