🕵️‍♀️ Dealing with Safari Incognito Mode: Authentication Gotchas and Workarounds with Auth0


We spent an embarrassing amount of time last month chasing a login bug that only reproduced on iOS Safari in private mode. Everything worked fine in Chrome, fine in regular Safari, fine on desktop. Private mode on iOS? Blank screen after login, or a silently blocked popup, or a redirect back with a query string that seemingly wasn't there. These are my notes on what actually goes wrong and the workarounds we ended up with, using auth0-spa-js.

What iOS private mode actually does

A few of the things that bit us:

  • localStorage, sessionStorage and cookies don't always survive cross-domain hops. Auth0's hosted pages at *.auth0.com rely on cookies to keep the login state, and those cookies don't make it back to your app domain.
  • Query parameters can just vanish after a redirect. The address bar still shows ?code=...&state=..., but window.location.search comes back empty. Fun one to debug.
  • Popups are only allowed if they're triggered synchronously from a user interaction. Any await or .then(...) before window.open and the popup blocker eats it.
  • window.opener is sometimes null for privacy reasons, which breaks the postMessage channel the popup login relies on.

Why popup login instead of redirect

In a normal browser loginWithRedirect() is the boring, reliable choice. In iOS private it's the one that fails hardest: the cookies between auth0.com and your domain are blocked, and when you do come back the ?code=... can go missing. You have no state and nothing to exchange.

loginWithPopup() keeps the user on your domain. The code flow happens in a popup, the popup posts the result back via window.opener.postMessage, and your SDK picks it up. But popups have their own gotchas, so it's not a free lunch.

The popup flow

Roughly what auth0-spa-js does:

  1. User clicks "Login".
  2. Your code calls auth0Client.loginWithPopup() — and this has to happen directly inside the click handler, no awaits in between.
  3. The SDK opens a popup pointing at /authorize on Auth0.
  4. After login the popup is redirected to your redirect_uri, typically something like /callback.html.
  5. That page grabs code and state, postMessages them to the opener, and closes itself.
  6. The SDK in the main window receives the message and finishes the exchange.
<!-- callback.html -->
<script>
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
 
  if (window.opener && code && state) {
    window.opener.postMessage(
      {
        type: 'authorization_response',
        response: { code, state }
      },
      window.location.origin
    );
  }
</script>

Gotcha: don't put async work before the popup opens

This is the one that got me:

button.addEventListener('click', () => {
  detectIncognito().then(isIncognito => {
    if (isIncognito) {
      auth0Client.loginWithPopup(); // blocked
    } else {
      auth0Client.loginWithRedirect();
    }
  });
});

By the time detectIncognito() resolves, Safari no longer considers the call part of the click. Popup gets blocked. The fix is to do the detection up front and cache the result:

let isIncognito = false;
 
detectIncognito().then(result => {
  isIncognito = result.isPrivate;
});
 
button.addEventListener('click', () => {
  if (isIncognito) {
    auth0Client.loginWithRedirect();
  } else {
    auth0Client.loginWithPopup();
  }
});

Ugly but it works. loginWithPopup() stays in the direct call path of the click.

Gotcha: missing query parameters after redirect

This one took me the longest to figure out. After the Auth0 redirect you land on your callback URL. The address bar clearly shows ?code=...&state=.... But window.location.search is empty and handleRedirectCallback() has nothing to work with.

The workaround I ended up with is to reload the page once, remembering in sessionStorage that we've already done it so we don't loop:

export function handleLoginReload() {
  const searchParams = new URLSearchParams(window.location.search);
 
  let hasOpenerWithSameOrigin = false;
  try {
    hasOpenerWithSameOrigin =
      !!window.opener &&
      window.opener.location.origin === window.location.origin;
  } catch (e) {
    hasOpenerWithSameOrigin = false;
  }
 
  const hasContentReloaded = sessionStorage.getItem('content-reloaded') === 'true';
  const hasAuthParams =
    searchParams.has('code') ||
    searchParams.has('error') ||
    searchParams.has('state');
 
  if (!hasOpenerWithSameOrigin || hasContentReloaded) {
    return;
  }
 
  sessionStorage.setItem('content-reloaded', 'true');
 
  if (!hasAuthParams) {
    console.warn('Reloading page to recover query parameters in Safari Incognito mode');
    window.location.reload();
  }
}

This needs to run before anything else touches handleRedirectCallback(), so I call it as early as possible in the app entry.

Detecting private mode

I ended up using detectincognitojs — it papers over the browser quirks reasonably well. The only thing to remember is that it's async, so you need to call it on page load and cache the result before the login button gets clicked.

Each iOS release has tweaked something: 14–15 restricted cookies and nulled window.opener, 16 tightened third-party storage further, 17 cranked up storage isolation again. The detection library has mostly kept up.

What I'd tell past-me

  • Default to loginWithPopup(), fall back to loginWithRedirect() when you detect private mode (or when the popup path fails).
  • Run your incognito check on page load, not on click.
  • Keep the popup call synchronous from the click.
  • If the callback page is missing query params, reload once and try again.

None of this is pretty. But once it's in place, the user doesn't see any of it, which is the whole point.