Skip to content

Bug: createBrowserClient overrides user-provided detectSessionInUrl and flowType, causing silent session destruction #175

@k6nmx

Description

@k6nmx

Repository

@supabase/ssr v0.6.1 (also affects @supabase/auth-js v2.70.0 error handling path)

Describe the bug

createBrowserClient silently overrides the user-supplied auth.detectSessionInUrl and auth.flowType options. The user's options are spread before the library's hardcoded values, so the library always wins:

// @supabase/ssr/dist/module/createBrowserClient.js (lines 29-39)
auth: {
    ...options?.auth,                    // user's settings (spread first)
    flowType: "pkce",                    // overrides user's flowType
    autoRefreshToken: isBrowser(),       // overrides user's autoRefreshToken
    detectSessionInUrl: isBrowser(),     // overrides user's detectSessionInUrl
    persistSession: true,
    storage,
},

This means:

  • detectSessionInUrl: false is ignored — it is always true in the browser.
  • flowType is always forced to "pkce", regardless of the user's setting.

Why this is a problem

The combination of these two overrides creates a destructive code path when a page loads with Supabase auth-related hash fragments in the URL (e.g. #access_token=... or #error=access_denied&error_code=otp_expired&...):

  1. _initialize() in @supabase/auth-js parses the URL hash.
  2. _isImplicitGrantCallback() returns true (because access_token or error_description is present in the hash).
  3. Since detectSessionInUrl was forced to true, the client enters the URL-processing code path.
  4. _getSessionFromURL() is called.
    • If the hash contains error params (error, error_description, error_code): throws AuthImplicitGrantRedirectError.
    • If the hash contains tokens (access_token, refresh_token): the flow-type check detects a mismatch (implicit URL vs forced pkce config) and throws AuthPKCEGrantCodeExchangeError.
  5. Both error types fall through to:
    // failed login attempt via url
    await this._removeSession();
  6. The existing valid session is destroyed — the cookie is set to Max-Age=0 and deleted from the browser.

How auth hash fragments end up on unrelated pages

This happens naturally through HTTP redirect fragment inheritance (RFC 7231 Section 7.1.2):

If the Location value provided in a 3xx response does not have a fragment component, a user agent MUST process the redirection as if the value inherits the fragment component of the URI reference used to generate the request target.

Example scenario with Next.js middleware:

  1. Supabase magic link (or error redirect) lands on /register/abc#access_token=... (or #error=access_denied&...).
  2. Next.js middleware detects the user is already logged in and redirects to /dashboard.
  3. The middleware redirect does not include a fragment.
  4. The browser inherits the hash: /dashboard#access_token=....
  5. On /dashboard, createBrowserClient initializes, detects the hash, hits a flow-type mismatch, and deletes the session cookie.

The user is now silently logged out on a page that has nothing to do with authentication. From the user's perspective, the session randomly disappeared.

To Reproduce

  1. Set up a Next.js app with @supabase/ssr v0.6.1 and @supabase/supabase-js v2.50.2.
  2. Create a browser client with detectSessionInUrl: false:
    const supabase = createBrowserClient(url, key, {
      auth: { detectSessionInUrl: false },
    });
  3. Establish a valid session (e.g. via magic link).
  4. Navigate to any page whose URL contains a Supabase auth hash fragment, e.g.:
    http://localhost:3000/some-page#access_token=expired_or_stale_token&refresh_token=x&token_type=bearer&type=signup
    
    This can happen naturally when middleware redirects from a magic-link landing page to another page — the browser inherits the fragment.
  5. Observe that the session cookie (sb-<ref>-auth-token) is deleted.

Expected behavior

  1. detectSessionInUrl: false should be respected. If the user explicitly passes detectSessionInUrl: false, the library should not override it. The current spread order makes the user's setting impossible to apply.

  2. flowType should be configurable. If there's a strong reason to default to "pkce", it should still be overridable by the user. The current code makes it impossible.

  3. _removeSession() should not be called on flow-type mismatches. Even when detectSessionInUrl is true, a flow-type mismatch between the URL params and the client config should not destroy an existing valid session. The mismatch indicates the URL hash is stale/irrelevant — not that the user's current session is invalid.

Suggested fix

Change the spread order in createBrowserClient so that user-provided options take precedence:

// BEFORE (user's options are overridden):
auth: {
    ...options?.auth,
    flowType: "pkce",
    detectSessionInUrl: isBrowser(),
    // ...
},

// AFTER (library provides defaults, user can override):
auth: {
    flowType: "pkce",
    autoRefreshToken: isBrowser(),
    detectSessionInUrl: isBrowser(),
    persistSession: true,
    ...options?.auth,   // user's options last = user wins
    storage,            // storage must still be forced
},

Additionally, consider not calling _removeSession() in @supabase/auth-js's _initialize() when the error is a flow-type mismatch (AuthPKCEGrantCodeExchangeError). A stale URL hash should not nuke a valid session.

Workaround

In Next.js middleware (or any server-side redirect), include a non-empty fragment in the redirect URL to prevent the browser from inheriting the original hash:

const redirectUrl = new URL("/dashboard", request.url);
redirectUrl.hash = "_"; // prevents RFC 7231 fragment inheritance
return NextResponse.redirect(redirectUrl);

Note: an empty fragment (#) gets stripped by the URL parser, so a non-empty placeholder like _ is required.

Environment

  • @supabase/ssr: 0.6.1
  • @supabase/supabase-js: 2.50.2
  • @supabase/auth-js: 2.70.0
  • Next.js 15 (App Router)
  • Browser: Chrome (also reproducible in other browsers that follow RFC 7231)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions