← All posts
·8 min read·GuardLayer

Fix: Supabase invalid flow state, no valid flow state

SupabaseAuthOAuthPKCENext.js

AuthApiError: invalid flow state, no valid flow state found means Supabase received an auth code but cannot find the matching PKCE flow row it created when login started. In almost every real case the OAuth provider is redirecting to your app instead of to Supabase's /auth/v1/callback, so the flow was never completed on Supabase's side. Point the provider's authorized redirect URI at https://<project-ref>.supabase.co/auth/v1/callback and pass your app URL as redirectTo instead.

It's a 403, it fires after a successful login at Google or GitHub, and it has the worst possible failure profile: it works for most people and fails for a few. Discussion #16743 — 14 comments and 8 replies deep — is the canonical thread, and the accepted answer is a redirect-URI correction, not a code change.

What a "flow state" actually is

PKCE splits a login across two requests that have to be tied back together. When you call signInWithOAuth, the client generates a random code verifier, hashes it into a code challenge, and Supabase stores that challenge server-side as a flow state keyed to the auth code it will eventually issue. When your callback route calls exchangeCodeForSession(code), Supabase looks up the flow state for that code and checks the verifier against the stored challenge.

no valid flow state found is the lookup failing. Not the verifier mismatching — that produces a different error about the code verifier being empty. This one means Supabase has no record of the flow at all.

That narrows the causes considerably. Either the flow state was never created, it was already consumed, or the code being presented was never issued by this Supabase project.

Why does Supabase say "no valid flow state found"?

Because the auth code reaching exchangeCodeForSession was not the one Supabase minted for a flow it is tracking. Four ways that happens, roughly in order of how often they're the answer.

1. The provider redirects to your app instead of to Supabase. This is the big one. The OAuth round trip has to go through Supabase: browser → Google → Supabase /auth/v1/callback → your app's callback route. If Google's Authorized redirect URI is set to https://yourapp.com/auth/callback, Google hands the code straight to your app. Supabase never sees the provider response, never finalizes the flow state, and then gets handed a code it has no record of. The redirect is only visibly wrong when it fails — the login itself succeeds, which is why this survives review.

2. The login started in one browser context and finished in another. The code verifier lives in a cookie or localStorage belonging to the browser that initiated the flow. Tap a link in the LinkedIn or Instagram in-app browser, get bounced to the system browser for Google's consent screen, come back — different storage, no verifier, and on a self-hosted or misconfigured setup that can surface as a flow-state failure rather than a verifier failure. This is why the bug reads as "fails for some users, works for most," and why mobile Chrome shows up repeatedly in the threads.

3. The code was already exchanged. Flow states are single-use. A callback route that runs twice — a link prefetch, a client-side effect firing under React Strict Mode, a user double-tapping — burns the flow state on the first pass and fails on the second. If your error appears alongside a working session, this is almost certainly it.

4. Local development points at the wrong host. Running supabase start, the Auth server is at http://127.0.0.1:54321, not at your Next.js dev server. The provider's redirect URI must be http://127.0.0.1:54321/auth/v1/callback, and config.toml has to list your app URL under additional_redirect_urls. A localhost / 127.0.0.1 mismatch counts as a different origin here, so pick one and use it everywhere.

Self-hosters hit a fifth variant: discussion #19795 reports the identical string on a self-hosted GoTrue with GitHub OAuth, where the container's GOTRUE_EXTERNAL_*_REDIRECT_URI disagreed with what the provider was configured with.

The fix: three places, one URL each

They are different URLs and the most common mistake is pasting the same one into all three.

In the provider's console (Google Cloud Console → Credentials, GitHub → OAuth Apps), the authorized redirect URI is Supabase's callback:

https://<project-ref>.supabase.co/auth/v1/callback

In the Supabase dashboard (Authentication → URL Configuration), Site URL is your production origin, and Redirect URLs lists every app callback you actually use:

https://yourapp.com/auth/callback
http://localhost:3000/auth/callback

In your app, redirectTo names your own callback route, and the exchange happens server-side:

// wherever the login button lives
const { error } = await supabase.auth.signInWithOAuth({
  provider: "google",
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
  },
});
// app/auth/callback/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");

  if (!code) {
    return NextResponse.redirect(new URL("/login?error=missing_code", origin));
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.exchangeCodeForSession(code);

  if (error) {
    // Includes "invalid flow state" — send the user back to start a fresh flow.
    return NextResponse.redirect(new URL("/login?error=auth", origin));
  }

  return NextResponse.redirect(new URL("/dashboard", origin));
}

Two details in that handler matter. The route is a Route Handler, not a page, so the exchange runs once per request instead of once per render — that closes cause #3. And it must be able to write cookies, which means the Supabase client has to be wired to cookies(); if it isn't, the exchange succeeds and the session still vanishes, which is the Route Handler session-missing failure wearing a different hat.

The fix that quietly opens a hole

When redirect URLs are the problem, the fastest way to make the error stop is to widen them until everything matches. Supabase's Redirect URLs field accepts wildcards, so https://yourapp.com/** or worse, https://*.yourapp.com/**, makes the mismatch go away in one edit.

Don't. That list is the allow-list Supabase checks before appending a session to a redirect. Widen it to a wildcard and any path on any matching host becomes a valid destination for a freshly minted session — including a user-controlled preview deployment, a subdomain you no longer operate, or an open redirect elsewhere in your app that bounces the token onward. The redirect_to value ends up carrying live credentials, which is exactly the kind of thing an app-layer security review is looking for.

Supabase's own documentation draws the line in the same place:

"While the 'globstar' (**) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production."

Enumerate the exact callback URLs you use. Three entries is normal. Wildcards should be limited to Vercel preview deployments, and even then scoped as tightly as the pattern allows.

Quick self-check

Run through this before touching any code:

  1. Open the provider console. Does the authorized redirect URI contain supabase.co/auth/v1/callback (or your self-hosted Auth origin)? If it contains your app's domain, that's the bug.
  2. In the Supabase dashboard, is your app callback listed under Redirect URLs verbatim, including the scheme and port?
  3. In DevTools, watch the Network tab through a full login. You should see a hop to <project-ref>.supabase.co/auth/v1/authorize, then the provider, then back to .../auth/v1/callback, then your app. A missing middle hop confirms cause #1.
  4. Does your callback live in app/auth/callback/route.ts? If the exchange is inside a page or a useEffect, move it.

Where a static scanner helps here — and where it doesn't

Honestly: not much, for the root cause. The redirect URIs live in the Supabase dashboard and the provider's console, not in your repository, so no static scan can see the mismatch. What it can see is the code around it — a callback route redirecting to a user-supplied URL, a server client trusting getSession(), an anon key wired into a path that assumed authentication. Fix the config by hand; leave the code patterns to something that checks every commit.

FAQ

Why does OAuth work for most users but fail for a handful? Because the failure depends on browser storage surviving the round trip. In-app browsers, aggressive privacy settings, and users who start login on one device and finish on another all break that, while a normal desktop login works every time.

Is this the same as both auth code and code verifier should be non-empty? No. That error means Supabase found the flow but your app couldn't produce the verifier — a cookie problem on your side. invalid flow state means Supabase couldn't find the flow at all. Different halves of the same handshake.

Can I just switch to the implicit flow to avoid PKCE? You can set flowType: "implicit", and you shouldn't. Implicit returns tokens in the URL fragment, where they land in browser history and any script on the page can read them. PKCE exists because implicit was deprecated for good reasons.

Do flow states expire? Yes — they're short-lived and single-use by design. A user who leaves the consent screen open for a long time, or who reloads your callback URL later, will see this error even with everything configured correctly.

I self-host Supabase. Anything different? Same shape, different file. Your GOTRUE_EXTERNAL_<PROVIDER>_REDIRECT_URI must match what the provider has, and GOTRUE_SITE_URL / GOTRUE_URI_ALLOW_LIST play the roles of Site URL and Redirect URLs.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.