← All posts
·7 min read·GuardLayer

Fix: auth code and code verifier should be non-empty

SupabaseAuthNext.jsOAuthPKCE

AuthApiError: invalid request: both auth code and code verifier should be non-empty means your callback route received the OAuth code but couldn't find the PKCE verifier that pairs with it. The verifier is stored client-side when the login starts, so the exchange must run in the same browser — with a server client wired to the request cookies.

The signature of this bug is that it works perfectly on localhost and fails the moment you deploy. Nothing about your callback code changed; what changed is where the verifier is stored and who can read it.

What PKCE is doing behind the scenes

PKCE ("proof key for code exchange") exists so that intercepting the authorization code isn't enough to steal a session. When you call signInWithOAuth or signInWithOtp, the client generates a random code verifier, stores it locally, and sends only a hash of it to the auth server. When the user comes back to /auth/callback with a code, your app must present the original verifier to prove it started the flow.

Supabase's docs state the constraint plainly:

"The code verifier is created and stored locally when the Auth flow is first initiated. That means the code exchange must be initiated on the same browser and device where the flow was started."

So exchangeCodeForSession(code) needs two inputs, not one. The code arrives in the URL. The verifier has to be read from storage. When the second one comes back empty, you get the error — and the message is accurate, which is why it's confusing: nothing is wrong with the code.

Why does my Supabase auth callback work locally but fail in production?

Because the verifier is stored somewhere the callback route can't reach. There are four realistic causes, and they're worth checking in this order.

1. The callback route isn't wired to the request cookies. With @supabase/ssr, the browser client writes the verifier to a cookie so that a server route can read it back. If your Route Handler creates a plain createClient() from @supabase/supabase-js instead of createServerClient() with a cookie adapter, it has no storage at all — so the verifier is always empty. This is the same class of mistake that makes getUser() return null on the server, and it's the most common cause by a wide margin.

2. The link was opened in a different browser. Magic links and email confirmations get opened in Gmail's in-app browser, Outlook's preview pane, or a corporate mail client's embedded webview — none of which share cookies with the browser that started the signup. The verifier is sitting in the original browser, unreachable.

3. Two flows started before either finished. If a user clicks "Sign in with Google", goes back, and clicks again, the second flow overwrites the first flow's verifier. Whichever code comes back first now has no matching verifier. Same story for a user who requests two magic links and clicks the older one.

4. The browser client and the server callback disagree about storage. A client configured to use localStorage writes the verifier where no server route can read it. Under @supabase/ssr the storage is cookies, and mixing the two libraries in one app is how apps end up with a verifier in localStorage and a callback route reading cookies. The sibling error message — code verifier could not be found in local storage — is the same failure seen from the client's side.

The fix

A correct App Router callback creates a server client bound to the request's cookies, exchanges the code, and lets setAll persist the resulting session:

// 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") ?? "/";
  const cookieStore = await cookies();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (list) =>
          list.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          ),
      },
    }
  );

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

  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) {
    return NextResponse.redirect(new URL("/login?error=exchange_failed", origin));
  }

  // Force the destination relative — never redirect to a raw query param.
  const safeNext = next.startsWith("/") && !next.startsWith("//") ? next : "/";
  return NextResponse.redirect(new URL(safeNext, origin));
}

Note that cookies() is awaited — on Next.js 15 and later it's async, and forgetting that breaks the Supabase server client in a way that can also present as a failed exchange.

Two more things to verify:

  • Your middleware must not intercept /auth/callback. If a matcher redirects unauthenticated requests to /login, the callback never runs — the user is bounced before the exchange, lands back at login, and loops forever. Scope the matcher deliberately and exclude the auth routes.
  • The redirect URL must be allow-listed. In Dashboard → Authentication → URL Configuration, add every origin you deploy to, preview deployments included. A rejected redirectTo sends the user somewhere without the code at all. If the provider is redirecting past Supabase entirely, the exchange fails differently — you get invalid flow state, no valid flow state found instead of a verifier error.

The trap in the next parameter

Look closely at the callback most people ship. It passes the destination through as a query parameter and redirects straight to it:

return NextResponse.redirect(searchParams.get("next") ?? "/");

That's an open redirect. An attacker sends https://yourapp.com/auth/callback?next=https://evil.example/login, the victim sees your real domain and your real login, and lands on a phishing page holding a fresh session. It's a particularly good phishing primitive precisely because it lives on the auth path, where users expect to be bounced around. Here's what GuardLayer reports on that file:

guardlayer scan · app/auth/callback/route.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/auth/callback/route.ts:26

    Open redirect from request input

    Never redirect straight to a user-supplied URL. Validate it against an allow-list of known paths/hosts first, or force it relative (only accept values starting with a single '/'). Note: this rule matches only the direct same-expression case — it does not track a tainted value across variables, so a laundered redirect can still be unsafe.

The fix is the two-line guard in the corrected route above: require the value to start with a single /, and resolve it against your own origin. Never redirect to a URL read straight from request input — a rule that applies well beyond auth callbacks, and is one of the app-layer checks worth automating.

Quick self-check

# 1. Does the callback use the SSR client with a cookie adapter?
grep -n "createServerClient\|createClient" app/auth/callback/route.ts

# 2. Is the callback excluded from middleware?
grep -n "matcher" middleware.ts

# 3. Any redirect straight to a query param?
grep -rn "redirect(.*searchParams.get" app/

In the browser, before you click through a login, check that a cookie named sb-<project-ref>-auth-token-code-verifier appears. If it never appears, the problem is on the client side of the flow, not in your callback.

FAQ

Can I just switch off PKCE and use the implicit flow? You can, and you shouldn't. The implicit flow returns tokens in the URL fragment, where they land in browser history and can leak via the referrer. PKCE is the default in @supabase/ssr for good reason.

Why does the error only affect some users? Almost always cause 2 or 3 — a mail client's embedded browser, or a user who started the flow twice. Both are invisible in your own testing and unavoidable in production, which is why the callback needs to fail gracefully rather than throw.

What's the difference between the "local storage" wording and the "non-empty" wording? Same root cause, different vantage point. code verifier could not be found in local storage comes from the client library failing to find it; both auth code and code verifier should be non-empty comes from the Auth server rejecting an exchange that arrived without one.

Does this apply to magic links and email confirmations too? Yes. Any flow that returns a code to your callback uses PKCE, so magic links, email confirmation, and password recovery are all affected by the same-browser requirement.

Should I retry the exchange automatically? No. A missing verifier can't be recovered by retrying — the state it needs doesn't exist. Redirect the user to a clean login with a readable message and let them start a fresh flow.

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.