Why Supabase Google Sign-In Landed on localhost With a Token in the URL
Google sign-in on a custom domain returned users to localhost:3000 with an access token in the address bar. Two separate causes, the Site URL fallback and the implicit flow, and the fixes.
The symptom
After adding "Continue with Google" to ResearchForge, a production sign-in on the custom domain ended at http://localhost:3000/?access_token=.... If you are using Supabase Auth with a custom domain or preview deployments and see the same thing, there are two independent causes, and only one of them is in your code. This is how each was diagnosed and fixed, for a Next.js frontend using @supabase/supabase-js in the browser.
The first instinct was to look for a hard-coded localhost. There was none: the redirect was already built from window.location.origin, which was correct. The bug was above it.
Cause one: Supabase substitutes the Site URL on the way back
Supabase Auth accepts any redirectTo when the flow starts. I verified this empirically: even a blatantly invalid redirect still returned a 302 to Google. The redirect is validated on the way back, and if it is not in the project's redirect allow-list, Supabase silently substitutes the project's Site URL.
The project's Site URL was still http://localhost:3000, and the production callback was not in the allow-list. That explains both halves of the symptom exactly: landing on / rather than /auth/callback is the substitution, and localhost:3000 is the Site URL. The same misconfiguration breaks email confirmation and password-reset links, which had failed the same way earlier.
So a missing allow-list entry does not look like a permissions error. It looks like a localhost URL. The fix is in the Supabase dashboard, not in code: set the Site URL to the production origin, and allow-list the exact callback and reset URLs for production and for local development. Watch the trailing slash, because allow-list matching is exact, and a redirect built as https://example.com//auth/callback or with a stray slash will silently fail to match.
export function authRedirectUrl(path: string): string {
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
const base = (
configured || (typeof window !== "undefined" ? window.location.origin : "")
).replace(/\/+$/, "");
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}Cause two: the implicit flow puts tokens in the URL
The access_token in the address bar was the second, separate defect. flowType had never been set, and plain @supabase/supabase-js defaults to the implicit flow, which returns the session by putting the access token and refresh token in the URL of the page it redirects to. Tokens in a URL reach browser history, can leak through a Referer header to any third-party asset the landing page loads, and are visible to anyone glancing at the screen. (@supabase/ssr defaults to PKCE; supabase-js does not, so relying on the default was fragile anyway.)
The client is now created with flowType: "pkce". The provider returns a single-use ?code= instead, which is worthless without the verifier held in this browser's own storage. Nothing secret is ever in a URL.
The callback page waits instead of exchanging the code
The obvious callback implementation calls exchangeCodeForSession(code). In this configuration that is a bug. With detectSessionInUrl: true, the client spots the code as it initialises and redeems it before the callback component mounts, and a one-time code cannot be redeemed twice. The explicit call would fail and the page would report a failure on a sign-in that had succeeded.
So /auth/callback waits for the session to appear through onAuthStateChange, with a 20-second timeout, and then forwards on. That approach is also flow-agnostic, which matters because a confirmation link issued before the switch to PKCE still had to work. Signup confirmation was changed to return to the same callback rather than to the dashboard, which also means confirmation and Google sign-in share one allow-listed URL.
Keeping the return path from becoming an open redirect
Where the user was going before sign-in is kept in sessionStorage, not in redirectTo, so only one plain URL needs allow-listing and the destination cannot be tampered with in a query string. Both the write and the read go through one check:
export function isSafeReturnPath(path: string | null | undefined): boolean {
if (!path || typeof path !== "string") return false;
if (!path.startsWith("/")) return false;
if (path.startsWith("//")) return false; // protocol-relative: //evil.example
if (path.startsWith("/\\")) return false; // browsers may treat /\ as //
return true;
}OAuth failures arrive as URL parameters rather than exceptions, in the query string or the hash, so both are checked and mapped to sentences a person can read. The redirect construction and the open-redirect check were extracted as pure functions, and 16 Vitest tests cover production and local redirect construction, the trailing-slash case, and rejection of the protocol-relative and backslash bypasses.
Checklist
- Site URL is the production origin, not localhost.
- Every callback and reset URL, production and local, is in the redirect allow-list, exactly as the code builds it.
flowType: "pkce"is set explicitly in the browser client.- The callback does not redeem a code the client already redeemed.
- Return destinations are same-site paths, checked on write and on read.
Same-origin routing made the rest of the deployment work on every domain, as described in Running FastAPI and Next.js as Two Services Behind One Vercel Origin. The account side of the system is covered in the ResearchForge case study.
The work behind this
Related
Keep reading
Running FastAPI and Next.js as Two Services Behind One Vercel Origin
One Vercel project, a Next.js frontend and a FastAPI backend on the same origin. The routing, the CORS outage it fixed, the image optimizer that vanished, and the Python packaging details.
Logout Still Worked for 30 Seconds: Token Caches on Serverless
A token verification cache meant a signed-out session kept working for up to 30 seconds. Why evicting on sign-out does not fix it on serverless, and why the answer was a small measured bound.
The Wrong Supabase Key Doesn't Fail. It Returns 200 and Zero Rows.
A publishable Supabase key in a server slot meant for a secret one: every read succeeded with no rows, the dashboard showed confident zeros, and the health check said all was well.