Supabase OAuth Callback Stuck at the Same URL in Angular — The detectSessionInUrl Race
Published: 2026-06-30

While building pulseticker — a real-time stock dashboard — I integrated GitHub OAuth via Supabase Auth into an Angular SPA. Login worked. GitHub redirected back. The console logged navigating to /dashboard. The URL stayed at /auth/callback.
No error. No failed request. Just a successful navigation that didn’t stick.
Quick context if you haven’t worked with Supabase’s two OAuth flows: implicit returns tokens directly in the URL fragment; PKCE returns a short-lived code that must be explicitly exchanged. The difference matters here.
Stage 0: Implicit Flow, Working
The initial AuthService used createClient() with no options:
private supabase: SupabaseClient = createClient(
environment.supabaseUrl,
environment.supabasePublishableKey,
// no auth options
);
In @supabase/[email protected], this defaults to flowType: 'implicit'. With the implicit flow, GitHub redirects back with the access token in the URL fragment (#access_token=...). Supabase picks it up automatically. The session existed. The guard passed. Everything worked.
The Symptom
At some point during development, the callback stopped completing. The login succeeded — network logs confirmed it, onAuthStateChange fired with SIGNED_IN, the session object was real. But after router.navigate(['/dashboard']), the browser URL reverted to:
http://localhost:4200/auth/callback?code=abc123...
The navigation logged correctly. The URL didn’t reflect it.
A False Lead
The redirect came back with ?code=... in the URL. I read that as a
sign that the PKCE code exchange was somehow incomplete — that
INITIAL_SESSION was firing before the exchange had a chance to
finish, and getSession() was catching it mid-flight.
That diagnosis doesn’t hold up, and in hindsight it’s obvious why:
the client was still configured for implicit flow at this point —
createClient() had no auth options set — and there’s no setting
anywhere in the Supabase Dashboard’s GitHub provider config that
switches flow type. Flow type lives entirely in the client library’s
options. A ?code= parameter showing up on an implicit-flow client
doesn’t square with how the SDK is supposed to behave. I don’t know
why the mismatch happened.
What I do know is that I diagnosed it wrong — I reasoned from the symptom to a plausible-sounding mechanism without checking whether PKCE was even active, and without checking whether the mechanism itself was real.
Checking the library source ruled it out directly. In
[email protected], getSession() awaits initializePromise, which
itself awaits the code exchange before resolving. The race I’d
imagined doesn’t exist in this version.
It wasn’t a library bug. It was a bad guess that didn’t survive contact with the source.
That guess turned out to be useless. But it pointed somewhere.
What Actually Raced
When flowType: 'pkce' is active and detectSessionInUrl is true (the default), Supabase automatically detects the ?code= parameter and runs the exchange. After the exchange completes, it cleans up the URL:
// GoTrueClient.ts — _getSessionFromURL(), PKCE branch
const { data, error } = await this._exchangeCodeForSession(params.code);
if (error) throw error;
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState(window.history.state, '', url.toString());
This replaceState() runs asynchronously, after the exchange. router.navigate(['/dashboard']) also runs asynchronously. They race.
In an Angular SPA the outcome is intermittent: sometimes the router wins and navigation completes normally; sometimes Supabase’s replaceState fires while the router’s internal state is mid-transition and overwrites the URL back to the callback route. The console says navigating to /dashboard. The address bar says otherwise.
This replaceState call only exists in the automatic detection path. The public exchangeCodeForSession() method never touches the URL.
The Fix
Disable automatic URL processing and own the exchange manually:
// supabase.service.ts — createClient config
createClient(environment.supabaseUrl, environment.supabasePublishableKey, {
auth: {
detectSessionInUrl: false, // avoid racing router.navigate()
flowType: 'pkce' // required for exchangeCodeForSession()
}
});
flowType: 'pkce' is required — exchangeCodeForSession() expects a PKCE flow. On the callback component:
async exchangeCode(code: string): Promise<Session | null> {
const { data, error } = await this.supabase.auth.exchangeCodeForSession(code);
if (error) return null;
return data.session;
}
This resolves the race. The router owns navigation timing; Supabase no longer calls replaceState.
One More Thing: The URL Isn’t Cleaned
There’s a side effect. With detectSessionInUrl: false, the cleanup block shown above never runs. The ?code= parameter stays in the URL after a successful exchange.
Verified live:
[AuthService] exchangeCode() called ... code: 53d08255-...
#_saveSession() {access_token: 'eyJ...', refresh_token: 'oodn3weqlde3', ...}
[AuthService] onAuthStateChange event: SIGNED_IN
[AuthService] exchangeCodeForSession() result: {data: {…}, error: null}
[AuthService] window.location.href after exchange:
http://localhost:4200/auth/callback?code=53d08255-...
Login succeeded. The code is still in the URL.
This isn’t a security issue — PKCE codes are single-use, and a stale code in the address bar can’t be replayed. But if the user refreshes after login, the callback component fires again with an expired code, which will fail. Most exchangeCodeForSession() examples target SSR route handlers, where a redirect response makes this moot. In a client-side SPA, you handle the cleanup yourself:
async exchangeCode(code: string): Promise<Session | null> {
const { data, error } = await this.supabase.auth.exchangeCodeForSession(code);
if (error) return null;
// exchangeCodeForSession() does not clean up the URL on its own —
// that cleanup only happens inside Supabase's automatic
// detectSessionInUrl path, which we've disabled to avoid racing
// the router. We own this step now.
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState(window.history.state, '', url.toString());
return data.session;
}
What This Took to Find
The original diagnosis was reasoning about the symptom, not the
actual library code. I assumed a timing race because that’s the kind
of bug INITIAL_SESSION and async exchanges usually produce — I
didn’t check whether it was actually happening here.
Reading GoTrueClient.ts settled it quickly. getSession() waits for
initializePromise, which itself waits for the exchange to finish
before resolving. The race I’d assumed doesn’t exist in this version
of the SDK.
What actually resolved this: reading the source, finding the
replaceState call inside _getSessionFromURL(), building a minimal
repro to confirm the real race, and checking the public
exchangeCodeForSession() path for the cleanup that isn’t there.
Further Reading
- Supabase Auth — Login with GitHub
— the official social login guide;
exchangeCodeForSession()examples here run inside a server-side route handler (Next.js, SvelteKit, Astro, Remix, Express), where a redirect makes the URL cleanup moot. - RFC 7636 — PKCE for OAuth 2.0
— the PKCE extension spec; the verifier-bound design described here is why an intercepted
?code=can’t be replayed without the original code verifier. - RFC 6749 §4.1.3
— the base OAuth 2.0 spec section that mandates authorization codes be single-use; servers must deny any re-attempt and should revoke tokens issued from that code. This is why a stale
?code=in the address bar isn’t a security risk.