Skip to content

fix(auth): resolve PKCE code_verifier loss during Google OAuth flow - #830

Merged
tran-christian merged 9 commits into
mainfrom
feat/auth-error-notification
Apr 16, 2026
Merged

fix(auth): resolve PKCE code_verifier loss during Google OAuth flow#830
tran-christian merged 9 commits into
mainfrom
feat/auth-error-notification

Conversation

@tran-christian

@tran-christian tran-christian commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Exclude /auth/callback from SSR middleware matcher so the session-refresh middleware never runs on the OAuth callback path and cannot clear the PKCE code_verifier cookie before exchangeCodeForSession completes
  • Conditional getUser() in proxy — only fires when a session token cookie (sb-{ref}-auth-token or chunked variants) is present; anonymous traffic makes zero auth-service round-trips, reducing blast radius of Supabase outages
  • Remove data?.session guard from callback route — non-standard check that blocked valid sessions
  • Narrow error-path cookie cleanup to only expire the specific PKCE *-auth-token-code-verifier cookie on failure, preserving any valid existing session when a transient Supabase error occurs during the exchange
  • Add proxy unit tests covering the conditional getUser() logic (no session, code-verifier-only, full token, chunked token)

Root cause

Two issues combined to break Google OAuth sign-in:

  1. The SSR middleware (proxy.ts) was running on the /auth/callback path. When users had stale session cookies, getUser() triggered a refresh_token_not_found error, causing the Supabase SDK to call setAll to clear session state — which inadvertently wiped the fresh code_verifier cookie before exchangeCodeForSession could read it.

  2. getClaims() (commit 537c139) does not contact the auth server and therefore never refreshes expired sessions server-side. Reverted to getUser(), now guarded behind a session-cookie presence check.

Test plan

  • Google OAuth sign-in completes successfully end-to-end
  • Users with stale/mismatched sb-* cookies from a previous session can sign in on the next attempt (PKCE verifier cleared on error; valid session preserved)
  • Anonymous page loads do not trigger an auth-service round-trip
  • pnpm jest src/lib/supabase/__tests__/proxy.test.ts — 5 tests passing

Without a root middleware.ts, Supabase SSR never refreshes the session
cookie on the server. This causes auth state to silently break after the
JWT expires (~1 hour) on server-rendered routes and the auth callback.

The updateSession helper already existed in src/lib/supabase/middleware.ts
but was never wired up as a Next.js middleware.

Matcher excludes static assets and images to avoid unnecessary overhead.
Next.js 16.0.0 deprecated middleware.ts in favor of proxy.ts, with the
exported function renamed from `middleware` to `proxy`. The old file was
being silently ignored on Vercel, which is why session refresh never ran
and auth broke.

Ref: https://nextjs.org/docs/messages/middleware-to-proxy
Matches Next.js 16 naming convention where the root proxy.ts now imports
from '@/lib/supabase/proxy' instead of '@/lib/supabase/middleware'.
Updates index.ts barrel export accordingly.
Replaces supabase.auth.getUser() with supabase.auth.getClaims() in the
session refresh proxy. getUser() makes a network round-trip on every
request and can cause random sign-outs in SSR; getClaims() reads JWT
claims locally and is the correct method for the proxy layer per
Supabase SSR docs.

Also adds .env*.local to .gitignore.
Exclude /auth/callback from SSR middleware matcher so session-refresh
logic cannot clear the code_verifier cookie before exchangeCodeForSession
runs. Revert getClaims() to conditional getUser() — only fires when a
session token cookie is present, avoiding an unconditional auth-service
round-trip for anonymous traffic. Remove non-standard data?.session guard
in callback route. Narrow error-path cookie cleanup to only expire the
PKCE code_verifier cookie, preserving valid sessions on transient errors.
Add proxy unit tests covering conditional getUser() behaviour.
@vercel

vercel Bot commented Apr 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
website Ready Ready Preview, Comment Apr 16, 2026 5:10am

@tran-christian
tran-christian merged commit ab5249f into main Apr 16, 2026
16 checks passed
@tran-christian
tran-christian deleted the feat/auth-error-notification branch April 16, 2026 05:10
tran-christian added a commit that referenced this pull request Apr 17, 2026
…st handling

Replaces the third-attempt manual Set-Cookie stamping with Supabase's
canonical @supabase/ssr App Router pattern and adds x-forwarded-host
handling — the single constraint from the official example that has
never been applied across #830, #832, or the current staged attempt.

Root cause: on Vercel, new URL(request.url).origin can resolve to an
internal load-balancer host rather than the public domain. When the
redirect lands on a different origin than the one @supabase/ssr stamped
Set-Cookie on, the browser appears to have lost the session.

Changes:
- Use createClient from @/lib/supabase/server (already canonical) instead
  of inlining createServerClient with a custom setAll
- Drop pendingCookies[] / response.headers.append('Set-Cookie', ...) and
  the toSetCookieHeader() helper — Next.js propagates cookies().set()
  mutations onto the response on its own
- Prefer x-forwarded-host in production redirects (canonical Supabase
  Next.js App Router example)
- Keep /auth/callback proxy exclusion (from #830) so session-refresh does
  not clear the code_verifier
- Keep a single _fh diagnostic param to confirm which redirect branch ran
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant