# PKCE errors: code verifier, cannot parse response

> Fix PKCE sign-in errors such as "code verifier not found", "both auth code and code verifier should be non-empty", flow_state_not_found and responses the client can't parse.

Source: https://supahost.app/kb/pkce-flow-errors · Updated 2026-11-11

## What you see

One of these, usually on the page that handles `?code=...` after OAuth or an email link:

```
AuthPKCECodeVerifierMissingError: PKCE code verifier not found in storage
AuthApiError: invalid request: both auth code and code verifier should be non-empty
```

Or an `AuthApiError` with `error_code` set to `flow_state_not_found`, `flow_state_expired` or `bad_code_verifier`. Sometimes the client can't read the response at all and reports an unknown error such as "cannot parse response" or `Unexpected token '<'`.

## Why it happens

PKCE is the sign-in flow used by `@supabase/ssr` and by most server-rendered apps. When sign-in starts, the client creates a secret code verifier, stores it (in a cookie with `@supabase/ssr`, in local storage with plain supabase-js) and sends Auth a hash of it. Auth later returns a one-time `code`, and `exchangeCodeForSession(code)` sends the code together with the stored verifier.

| Error | Usual cause |
|---|---|
| Code verifier not found, or "should be non-empty" | The link was opened in a different browser or device (common with email links opened from a mail app), storage was cleared, or the client that started sign-in couldn't save cookies. |
| `flow_state_not_found` | The code was already exchanged: a page refresh, React running an effect twice, or your code exchanging it while the client also detected it in the URL. |
| `flow_state_expired` | Too long passed between starting sign-in and coming back. |
| `bad_code_verifier` | The verifier in storage came from a different sign-in attempt, often from two attempts in two tabs. |
| Cannot parse response | The request never reached Auth. The project URL is wrong (has `/auth/v1` or `/rest/v1` on the end, a typo, `http` instead of `https`), or a proxy returned an HTML page. |

## How to fix it

1. **Use `@supabase/ssr` on both sides in server-rendered apps.** `createBrowserClient` in the browser and `createServerClient` (with `getAll` and `setAll`) on the server, so the verifier is stored in cookies both can read. See [Server-side auth and SSR](/docs/auth/server-side).

2. **Exchange the code exactly once.** A browser client using PKCE (the default for `createBrowserClient`, or `flowType: "pkce"` with plain supabase-js) detects `?code=` and exchanges it automatically, because `detectSessionInUrl` is on by default. Don't also call `exchangeCodeForSession()`. If you prefer to do it yourself, turn detection off:

   ```ts
   const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, {
     auth: { flowType: "pkce", detectSessionInUrl: false },
   });

   const code = new URL(window.location.href).searchParams.get("code");
   if (code) await supabase.auth.exchangeCodeForSession(code);
   ```

3. **Give email links a fallback.** If users often open emails on another device, let them type the one-time code that every Auth email includes:

   ```ts
   await supabase.auth.verifyOtp({ email, token: code, type: "email" });
   ```

4. **Check the project URL.** It should be the bare project address, with no path:

   ```bash
   curl -H "apikey: $SUPABASE_ANON_KEY" https://app-one.acme.supahost.space/auth/v1/health
   ```

   A JSON reply means the URL is right. Use exactly that base (without `/auth/v1/health`) in `createClient`.

5. **Retry from the start.** For expired or already-used flows, send the user back to the sign-in page. Retrying the same callback URL will keep failing.

## On SupaHost

- Email templates can't be switched to a link format your own server verifies (the button link is added automatically). In server-rendered apps, use PKCE with a callback route, or the one-time code.
- Unknown paths on the project host return a JSON 404 (`no Route matched with those values`), so a parse error usually points to a typo in the host or a proxy in front of it.

## Related

- [Server-side auth and SSR](/docs/auth/server-side)
- [OAuth sign-in not redirecting on the server](/kb/oauth-server-side-redirect)
- [OTP or magic link "token has expired"](/kb/otp-expired)
- [Auth in Next.js (App Router)](/docs/auth/nextjs)
