Supabase OAuth Callback Stuck at the Same URL in Angular — The detectSessionInUrl Race

Published: 2026-06-30

Authentication Code Exchange Image

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.