Supabase

Supabase AuthSessionMissingError in Middleware: 5 Fixes

Auth errors crashing your Next.js middleware? Learn how to handle Supabase auth errors gracefully with proper error handling patterns.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
10 min read

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.

This page covers handling Supabase auth errors in Next.js middleware so the app stays usable when authentication fails. The cookie wiring it builds on is the one in Supabase's server-side Next.js guide; what follows is the error handling that guide leaves to you.

Next.js 16 renamed the middleware.ts convention to proxy.ts (same export async function, renamed to proxy) to make clear it's a network boundary, not Express-style middleware. middleware.ts still works — it's deprecated, not removed — and everything below applies to both; if you're on Next.js 16+, rename the file and the exported function and the rest is unchanged.

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#

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

The 5 Fixes: Diagnosing Each Named Error#

supabase.auth.getUser() in middleware doesn't fail with one generic error — it throws or returns a handful of distinct, named errors, each with a different root cause. Treating all of them the same way (a blanket if (error) redirect('/login')) is how a transient network blip turns into a redirect loop for every user. Here's what each one actually means and the specific fix for it.

1. AuthSessionMissingError#

Thrown by getUser() (and getSession()) when there is no session for the client to read. In middleware this almost always means one of: the user is genuinely signed out, the cookies never reached the server client because they weren't forwarded through request.cookies / response.cookies in the createServerClient cookie handlers, or getUser() fired on a route the cookie middleware never ran on. Check error.name === 'AuthSessionMissingError' — do not redirect protected routes into a login loop if this fires on a route that should be public.

typescript
if (error?.name === 'AuthSessionMissingError') {
  // No session — this is expected for signed-out visitors on public routes
  if (!request.nextUrl.pathname.startsWith('/dashboard')) {
    return response
  }
  return NextResponse.redirect(new URL('/login', request.url))
}

2. AuthRetryableFetchError#

auth-js wraps a failed HTTP call to the Supabase Auth (GoTrue) server in this error class — a network timeout, a dropped connection, or the Auth server itself returning a 503. A 503 paired with AuthRetryableFetchError specifically can mean the Auth service failed to initialize because of a bad GOTRUE_SESSIONS_TIMEBOX value in your project's Sessions settings, not a code bug — check the Supabase dashboard before debugging further. Because this class of error is retryable, it's the one case where retry logic (Step 5 below) is worth it; the other named errors here are not. That only holds when the failing call is the /user request itself on an otherwise-valid session — if the error actually came from a failed token refresh, auth-js caches that failure for 60 seconds (see the caveat under Step 5), and retrying inside the same request just reads the cached result.

typescript
if (error?.name === 'AuthRetryableFetchError') {
  // Transient — safe to retry, and safe to fail open for public routes
  if (!request.nextUrl.pathname.startsWith('/dashboard')) {
    return response
  }
  return NextResponse.redirect(new URL('/login?error=service_unavailable', request.url))
}

3. 406 responses from a profile lookup in middleware#

This one isn't from supabase.auth at all — it comes from a .from('profiles').select().single() query some middleware chains run right after getUser() to check onboarding state or a role. PostgREST returns 406 Not Acceptable with the message Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row when .single() expects exactly one row and gets zero (a common case: the auth.users row exists but the trigger that inserts the matching profiles row hasn't run yet, or failed). The fix is to swap .single() for .maybeSingle(), which returns null instead of throwing:

typescript
const { data: profile } = await supabase
  .from('profiles')
  .select('onboarded')
  .eq('id', user.id)
  .maybeSingle() // not .single() — avoids a 406 when the row doesn't exist yet

4. Redirect loops (Error → Login → Error → Login)#

This happens when the login page itself triggers the same middleware chain and the same error fires again — for example, AuthSessionMissingError on /login gets treated as "protected route, redirect to /login" because the pathname check didn't exclude the login route itself. Always exclude the auth routes from the protected-route check, and never redirect a request whose pathname is already /login.

5. Auth calls that hang instead of erroring#

Not every failure surfaces as a rejected promise — a Supabase outage can leave getUser() pending indefinitely, which stalls the whole request instead of erroring. Promise.race() against a timeout (Step 3's "Complete Error-Resilient Middleware" below) turns a hang into an explicit, handleable error instead of a timed-out request.

Step-by-Step Solution#

Step 1: Create Error-Safe Middleware#

Wrap all auth operations in try-catch blocks:

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

typescript
// lib/auth-errors.ts
export type AuthErrorType = 
  | 'network'
  | 'expired'
  | 'invalid'
  | 'unauthorized'
  | 'unknown'
 
export function categorizeAuthError(error: any): AuthErrorType {
  // Check the named error class first — this is more reliable than
  // matching on `error.message`, which auth-js does not guarantee is stable.
  if (error?.name === 'AuthRetryableFetchError') {
    return 'network'
  }
 
  if (error?.name === 'AuthSessionMissingError') {
    return 'unauthorized'
  }
 
  // PostgREST 406 from a `.single()` query with 0 or >1 rows
  if (error?.status === 406 || error?.code === 'PGRST116') {
    return 'invalid'
  }
 
  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:

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

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

typescript
// lib/auth-retry.ts
export async function retryAuthOperation<T extends { error: any }>(
  operation: () => Promise<T>,
  maxRetries: number = 3,
  delayMs: number = 1000
): Promise<T> {
  let result: T
 
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // supabase-js returns { data, error } — getUser() does not throw on an
    // AuthRetryableFetchError, so a try/catch around it never sees that
    // error. Retrying must check the returned error instead.
    result = await operation()
 
    if (!result.error || result.error.name !== 'AuthRetryableFetchError') {
      return result
    }
 
    // Wait before retrying
    if (attempt < maxRetries - 1) {
      await new Promise(resolve => setTimeout(resolve, delayMs * (attempt + 1)))
    }
  }
 
  return result!
}

This only retries a real network blip on the /user call itself. If getUser() failed because the token refresh behind it failed — not because /user itself was unreachable — auth-js (@supabase/auth-js 2.118.0) caches that refresh failure for REFRESH_FAILURE_COOLDOWN_MS (60 seconds) and returns it directly on the next call with the same refresh token, without making a new request. retryAuthOperation's delays (500ms, 1000ms by default) are far shorter than that window, so every retry inside the same request just reads the cached failure. A later request still works once the cooldown passes, since that one gets its own fresh client — only the retries within one request are the no-op. There is no way to tell the two cases apart from the returned error alone, so for a refresh-caused failure the safest move is the same fail-open/fail-closed decision Sections 1–4 make immediately, without spending a retry on it.

Use retry logic in middleware:

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

typescript
// 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)$).*)',
  ],
}

Advanced Error Handling Patterns#

Retry Logic for Transient Errors#

typescript
// 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 {
  if (error?.name === 'AuthRetryableFetchError') return true
  const message = error?.message?.toLowerCase() || ''
  return (
    message.includes('fetch failed') ||
    message.includes('timeout') ||
    message.includes('network') ||
    message.includes('econnrefused')
  )
}

Same caveat as the first retry helper above: this only helps when /user itself is the thing that failed. A refresh-caused AuthRetryableFetchError is cached for 60 seconds, so the 100ms/200ms/400ms delays here never outlast it — those retries read the cached failure instead of making a request.

Use Retry in Middleware#

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

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

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

typescript
// __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#

plaintext
□ 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#

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

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

  • Mistake #6: Not setting timeouts - Supabase auth calls can hang indefinitely. Always set a timeout (5 seconds recommended).

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 Supabase auth errors in Next.js middleware requires careful error handling, retry logic, and graceful degradation. The key is ensuring that:

  1. Public routes remain accessible even if auth fails
  2. Protected routes redirect to login on auth errors
  3. Network errors are retried, but auth errors are not
  4. 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.

Further reading: Supabase Email Confirmation Not Sending: 5 Fixes (2026). Also see Supabase Magic Link: The Production Checklist. Also see Supabase Auth Error Codes: Fixes + TS Cheat Sheet (2026).

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.