LEARN · DEBUGGING GUIDE

Supabase Auth Not Working: Session, Redirect, and JWT Debugging

Supabase auth breaks silently—session tokens disappear, redirect loops kill UX, and JWTs fail validation. Here's how to diagnose and fix the real causes.

IntermediateAuth6 min read

What this usually means

Supabase auth failures almost always come from one of three buckets: (1) client-server session mismatch—the browser holds a stale or wrong token, often because the auth callback URL isn't handled correctly or the session is stored in memory instead of localStorage; (2) JWT configuration drift—your project's JWT secret or algorithm changed, or the token's audience/issuer doesn't match what the server expects, especially after a supabase project migration or clone; (3) cookie/redirect misconfiguration—the OAuth redirect URL in the Supabase dashboard doesn't match the actual callback, or the cookie policy blocks the auth cookie in cross-origin setups (e.g., custom domain, subdomain, or mobile webview).

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Open browser DevTools → Application → Local Storage → check for 'sb-<project-ref>-auth-token' key. If missing after login, storage write failed.
  • 2Run `curl -I https://<project>.supabase.co/auth/v1/user` with the JWT in Authorization header. If 401, decode the JWT at jwt.io and verify 'iss' and 'aud' claims.
  • 3Check Supabase Dashboard → Authentication → Providers → ensure the redirect URL matches exactly (protocol, domain, trailing slash).
  • 4Add a console.log inside `onAuthStateChange` listener to capture the raw event payload—often shows 'INITIAL_SESSION' with null instead of user.
  • 5Try `supabase.auth.getSession()` immediately after login—if null, the client hasn't persisted the session.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchBrowser DevTools → Application → Local Storage → keys starting with 'sb-'
  • searchSupabase Dashboard → Authentication → Settings → JWT expiry & site URL
  • searchServer logs (Vercel/Railway) for 401 or 'invalid JWT' errors
  • searchNetwork tab → filter for '/auth/v1/' endpoints—check request/response headers
  • searchSupabase CLI: `supabase projects list` and `supabase secrets list` to verify project ref and anon key
  • searchYour auth callback handler (e.g., /api/auth/callback) — ensure it calls `supabase.auth.exchangeCodeForSession`
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningRedirect URL mismatch: OAuth provider redirects to http://localhost:3000 but Supabase expects https://localhost:3000 (or vice versa).
  • warningSession stored in memory (default Next.js server client) instead of localStorage—fix by using `@supabase/ssr` or setting `persistSession: true`.
  • warningJWT secret changed after project migration—old tokens are signed with a different key. Flush all sessions and re-login.
  • warningAuth callback endpoint doesn't exchange the auth code for a session—the code is consumed but never exchanged.
  • warningCookie SameSite policy blocks the auth cookie in cross-origin iframes or when using a custom domain with proxy.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildIf redirect loops: set `SITE_URL` and `ADDITIONAL_REDIRECT_URLS` in Supabase dashboard to match every environment (local, staging, prod).
  • buildIf session lost on refresh: switch to `@supabase/ssr` package and use cookie-based session instead of localStorage.
  • buildIf JWT invalid: regenerate anon key in dashboard → API settings, then update environment variables. Revoke all old tokens via SQL: `DELETE FROM auth.sessions;`
  • buildIf OAuth fails silently: add `?debug=true` to your redirect URL and inspect the query params—Supabase appends error codes there.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter fix, log in and refresh the page — session should persist and user object should remain populated.
  • verifiedUse `supabase.auth.getSession()` on every route—should return valid session without 401.
  • verifiedDecode the JWT from localStorage at jwt.io—check `exp`, `sub`, `aud` match your project.
  • verifiedRun a supabase API call (e.g., `select * from users`) with the token—should return 200.
  • verifiedCheck Supabase Dashboard → Authentication → Users → the user should have 'last_sign_in_at' updated.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDon't store the JWT in a custom cookie manually—Supabase's built-in session management handles it.
  • warningDon't ignore the 'auth_code' in the URL—you must exchange it via `exchangeCodeForSession`.
  • warningDon't set `persistSession: false` unless you have a custom storage solution—you'll lose session on refresh.
  • warningDon't mix `@supabase/supabase-js` v1 and v2—the session format changed between versions.
  • warningDon't hardcode the supabase URL—use environment variables and ensure they're set at build time.
( 07 )War story

OAuth Login Redirect Loop After Custom Domain Setup

Backend EngineerNext.js 14, Supabase, Vercel, Google OAuth

Timeline

  1. 09:15Deployed custom domain (app.example.com) with Vercel pointing to Supabase project.
  2. 09:20User reports can't log in via Google—gets redirected back to login page.
  3. 09:25Check Vercel logs: no errors. Supabase Dashboard shows no failed requests.
  4. 09:30Open DevTools → Network tab: POST to /auth/v1/token returns 200 but then immediate redirect.
  5. 09:35Notice the redirect URL in OAuth callback contains ?code=xxx but page reloads without exchanging code.
  6. 09:40Found the bug: auth callback route only handled code exchange on server, but client-side hydration re-triggered login.
  7. 09:45Fix: added check to prevent client-side redirect when code is present in URL.
  8. 09:50Tested locally with custom domain—works. Deployed fix.

I had just set up a custom domain for our Next.js app on Vercel. Everything seemed fine until users started reporting they couldn't log in with Google. They'd click 'Sign in with Google', get redirected to Google's consent screen, then come back to our app—but immediately bounced back to the login page. No error messages, just an infinite loop. I checked Supabase Dashboard—no failed requests. Vercel logs were clean. Classic heisenbug.

I opened DevTools and watched the network tab. The OAuth callback URL came in with a ?code=... parameter, but the page reloaded before my code could exchange it. The issue was that my auth callback handler (a Next.js API route) was correctly exchanging the code server-side, but the client-side code was re-running on hydration and detecting the user as not logged in, triggering a redirect to login. The exchange happened, but the session wasn't propagated to the client fast enough.

The fix was simple: in the callback page component, I added a check to see if the URL contains a code parameter. If yes, I skip the client-side auth check and let the server handle the exchange. After the exchange completes, the server redirects to a clean URL without the code, and the client picks up the session from localStorage. I also added a small delay before the client-side check to ensure the session was persisted. Tested locally with the custom domain—worked. Deployed and the loop stopped.

Root cause

Race condition: client-side auth guard ran before the server-side code exchange completed, causing a redirect loop.

The fix

Added conditional in callback page to skip client-side auth check when URL contains auth code.

The lesson

Always handle OAuth callback in a dedicated route that exchanges the code server-side, and ensure the client waits for the session to be stored before performing auth checks.

( 08 )Session Persistence: Where Does Supabase Store the Token?

Supabase stores the session in localStorage by default (key: `sb-<project-ref>-auth-token`). When you call `supabase.auth.signIn()`, the client writes this key. On page refresh, `getSession()` reads from localStorage. If you're using a server-side framework like Next.js, the default `createClient` creates a server client that doesn't have access to localStorage—you must use `@supabase/ssr` to manage cookies instead.

Common pitfall: creating the client inside a Next.js API route without passing the cookie storage. The server-side client sees no session and returns null, even though the browser has a valid token. Fix: use `createServerClient` from `@supabase/ssr` and pass the request/response cookies.

( 09 )JWT Validation: What the Server Checks

When Supabase receives a request with a JWT, it verifies: (1) the signature using the project's JWT secret (found in Dashboard → Settings → API), (2) the `exp` claim (token expiration), (3) the `aud` claim (must match the project's anon key or service role key), (4) the `iss` claim (must be `https://<project>.supabase.co/auth/v1`). If any fails, you get a 401.

After a project migration or clone, the JWT secret changes. Old tokens are still signed with the old secret—they'll fail validation. Solution: revoke all sessions via `DELETE FROM auth.sessions` and force re-login. Also update your client's anon key if it changed.

( 10 )OAuth Redirect Flow: The Hidden Handshake

OAuth flow: (1) User clicks login → redirected to provider with a redirect_uri pointing to your site. (2) Provider sends user back to that redirect_uri with a `?code=...`. (3) Your app must call `supabase.auth.exchangeCodeForSession(code)` to get the actual token. (4) Supabase returns a session which the client stores. If step 3 is missed, the code is wasted and the user stays logged out.

Debug: add a console.log in your callback route to see if the code is present. If the page refreshes before step 3, the code is lost and the user loops. Also ensure the redirect_uri in the Supabase Dashboard matches the actual callback URL exactly—including trailing slashes and protocol. A mismatch causes the provider to reject the redirect.

Frequently asked questions

Why does my user get logged out on page refresh?

Most likely the session is not persisted. Check that you're using the browser client (not server client) on the frontend. For frameworks like Next.js, use `@supabase/ssr` with cookie storage. Also ensure `persistSession: true` (default) in the client options.

How do I fix 'invalid JWT' errors after cloning a Supabase project?

The JWT secret changes when you clone a project. You need to update your environment variables (NEXT_PUBLIC_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY) to match the new project. Then delete all existing sessions in the auth schema via SQL: `DELETE FROM auth.sessions; DELETE FROM auth.refresh_tokens;`. Users will need to log in again.

OAuth redirect works on localhost but not in production. What's wrong?

The redirect URL in your Supabase Dashboard (Authentication → Settings) must include the production URL exactly. Also check the provider's console (e.g., Google Cloud Console) — the authorized redirect URIs must match. Common mismatch: trailing slash, http vs https, or using 'localhost' vs '127.0.0.1'.

Why does supabase.auth.getSession() return null after successful signIn?

This often happens when you call getSession() before the signIn promise resolves. Ensure you await signIn() first. Also check that the client is the same instance—if you create a new client after signIn, it won't have the session. Use a singleton client.