SmithGuidesFixing Supabase Auth in a Bolt.new App

Bolt.newAuth RescueIntermediate

Fixing Supabase Auth in a Bolt.new App

OAuth loops, sessions not persisting, and JWT errors — a complete step-by-step rescue for the most common Supabase auth failures in Bolt-generated apps.

14 min read·By Smith Community·July 7, 2026·Edit on GitHub

The problem

Your Bolt.new app has a login page that looks great. But one or more of these is broken:

  • Users log in with Google/GitHub and get sent back to the login page in a loop
  • The session disappears on page refresh — users have to log in every time
  • useUser() or supabase.auth.getUser() returns null even after login
  • Protected routes are accessible without authentication
  • You see JWT expired or Invalid JWT errors in the console

These are the four most common Supabase auth failures in Bolt-generated code, and they all have the same root cause: Bolt generates client-side auth patterns that don't work when combined with Next.js server components and middleware.

Why Bolt gets this wrong

Bolt generates sensible auth code for a pure client-side React app. The problem is that most Bolt projects use Next.js, and Next.js App Router requires a different approach.

Specifically, Bolt typically generates:

// ❌ What Bolt generates — works in client components, breaks everywhere else
import { createClient } from '@supabase/supabase-js'
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

This single client is used everywhere — in server components, in middleware, in API routes. It breaks because:

  1. It uses the anon key for everything (can't access server-side session)
  2. It doesn't know how to read cookies in a server context
  3. Middleware can't verify the session, so protection fails silently

The fix is replacing this with @supabase/ssr — Supabase's package built specifically for server-side rendering.

The fix

Step 1: Install @supabase/ssr

npm install @supabase/ssr

Remove the old client import from wherever Bolt put it — usually a lib/supabase.ts or utils/supabase.ts file.

Step 2: Create a browser client (for client components)

Create lib/supabase/client.ts:

import { createBrowserClient } from '@supabase/ssr'
 
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

Step 3: Create a server client (for server components and API routes)

Create lib/supabase/server.ts:

import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // setAll called from a Server Component — middleware will handle session refresh
          }
        },
      },
    }
  )
}

Step 4: Create middleware to keep sessions alive

This is the step Bolt almost never generates, and it's why sessions disappear on refresh. Without middleware, Supabase's session token isn't refreshed between requests.

Create or replace middleware.ts at the root of your project:

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })
 
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )
 
  // Refresh the session — this is what keeps users logged in across page loads
  const { data: { user } } = await supabase.auth.getUser()
 
  // Redirect unauthenticated users away from protected routes
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth') &&
    !request.nextUrl.pathname.startsWith('/')  // adjust: add your public routes
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }
 
  return supabaseResponse
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Critical: The supabaseResponse object must be returned from middleware — not a plain NextResponse.next(). The session cookie updates are written into this response object. If you create a new response object and return it instead, the session is silently lost.

Step 5: Fix the OAuth callback route

Bolt often generates an incomplete callback handler. Replace or create app/auth/callback/route.ts:

import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/'
 
  if (code) {
    const cookieStore = await cookies()
    const supabase = createServerClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      {
        cookies: {
          getAll() {
            return cookieStore.getAll()
          },
          setAll(cookiesToSet) {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          },
        },
      }
    )
 
    const { error } = await supabase.auth.exchangeCodeForSession(code)
 
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }
 
  // Return to login with error — don't silently redirect to home
  return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`)
}

Step 6: Update your Supabase project's redirect URLs

In the Supabase dashboard → Authentication → URL Configuration:

  • Site URL: https://yourdomain.com (your production URL)
  • Redirect URLs: Add both:
    • https://yourdomain.com/auth/callback
    • http://localhost:3000/auth/callback (for local dev)

If you forget this step, OAuth will work locally but silently fail in production.

Step 7: Update all auth calls to use the right client

In client components (files with 'use client' at the top):

import { createClient } from '@/lib/supabase/client'
 
export function LoginButton() {
  const supabase = createClient()
 
  const handleLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    })
  }
 
  return <button onClick={handleLogin}>Sign in with Google</button>
}

In server components (no 'use client'):

import { createClient } from '@/lib/supabase/server'
 
export default async function Dashboard() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) return null // middleware should handle this, but belt-and-suspenders
 
  return <div>Welcome, {user.email}</div>
}

Never use getSession() in server components — use getUser(). getSession() returns cached data that isn't re-validated against the server, which means you can get stale session data after a user logs out.

Verify it works

After making these changes:

  • Sign in with email/password → you should land on your dashboard and stay there on refresh
  • Sign in with Google/GitHub → no loop, lands on dashboard, stays on refresh
  • Open a protected route in an incognito window without logging in → should redirect to /login
  • Sign in, then open browser DevTools → Application → Cookies. You should see sb-* cookies set
  • Hard refresh (Cmd+Shift+R) while logged in → you should still be logged in

Common mistakes

Mistake 1: Mixing the old createClient with the new SSR clients

If you have any file still importing from the old @supabase/supabase-js single client, sessions will break inconsistently. Search your whole project:

grep -r "from '@supabase/supabase-js'" --include="*.ts" --include="*.tsx" .

Every result should be replaced with the appropriate lib/supabase/client or lib/supabase/server import.

Mistake 2: Returning a new response from middleware instead of supabaseResponse

// ❌ This loses the session cookie updates
if (!user) {
  return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next() // ← loses cookies
 
// ✅ Return supabaseResponse (which has the cookies) for the redirect
if (!user) {
  const url = request.nextUrl.clone()
  url.pathname = '/login'
  return NextResponse.redirect(url) // redirect is fine
}
return supabaseResponse // ← returns updated cookies

Mistake 3: Using getSession() instead of getUser() in server code

getSession() reads from the cookie without verifying with Supabase's servers. A manipulated cookie would pass. getUser() makes a network call to verify — it's the right one for anything security-sensitive.

Mistake 4: Forgetting to add localhost to Supabase redirect URLs

Supabase blocks redirects to any URL not in your allow-list. Add both your production URL and http://localhost:3000 during development. Without this, OAuth works nowhere or only in one environment.

Going further

Once auth is working:

  • Set up Row Level Security (RLS) so users can only read their own data
  • Add role-based access: store roles in public.profiles and check them in middleware
  • See the AI Build Security Checklist — auth being broken is usually the first of several security issues in AI-generated code

This rescue typically takes a Smith 3–5 hours including testing across OAuth providers. If you'd rather have it done right the first time, post an Auth Rescue job on Smith.

Tags:authsupabaseoauthsessionsbolt

Rather have it done for you?

This is exactly what Smith rescues.

Vetted AI builders who specialize in auth rescue work. Escrow-protected, done in days — not weeks.