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

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


Authenticating users in modern web applications is already a delicate dance across redirects, tokens, and storage. But throw Safari’s Incognito Mode (a.k.a. Private Browsing) into the mix — especially on iOS — and that dance turns into a minefield.

In this post, we’ll dive into:

  • The unique limitations of iOS Safari Private Mode

  • Why popup login with auth0-spa-js is necessary — but tricky

  • How browser features like window.opener, storage, and query parameters behave differently

  • How to detect incognito mode reliably

  • How to design a working authentication flow, even with these constraints


🔐 The Problem with Private Mode on iOS Safari

Safari Private Browsing introduces a few significant constraints:

1. No access to storage between domains

  • localStorage, sessionStorage, and even cookies may not persist between hops — especially cross-domain.

  • Auth0’s hosted login pages (e.g., *.auth0.com) often rely on cookies to maintain login state, which breaks in incognito.

2. Query parameters disappear on navigation

  • After redirection, Safari may strip or block query parameters on deep links or from third-party-redirects.

  • In a login flow, the ?code=...&state=... parameters may get lost or become inaccessible — breaking handleRedirectCallback().

3. Popups are heavily restricted

  • window.open() is blocked unless triggered synchronously by a user interaction (e.g. a click).

  • Async login checks (e.g., "am I incognito?") delay execution and cause popup blockers to kick in.

4. window.opener may be null

  • For privacy, Safari sometimes sets window.opener = null in Private Mode — which breaks postMessage()-based communication between the popup and parent window.

🧰 Why Use loginWithPopup() Instead of loginWithRedirect()

In theory, loginWithRedirect() is simple and reliable — just redirect to Auth0 and back again.

But in Safari Private:

  • Cookies between the Auth0 domain and your app are blocked

  • Query parameters (?code=...) can get stripped

  • Storage is unavailable — meaning the state can't be validated

So we prefer loginWithPopup(), because:

  • The user stays on your domain

  • The code flow happens in a popup that can store tokens in the opener

  • The redirect URI in the popup sends the result via window.opener.postMessage(...) to your app

But even the popup option comes with caveats.


🪟 How the Popup Login Flow Works

Here’s how auth0-spa-js manages a popup login:

  1. User clicks "Login".

  2. Your app calls auth0Client.loginWithPopup() (must be in a direct click handler).

  3. The SDK opens a popup to the Auth0 /authorize endpoint.

  4. After login, the popup is redirected to your redirect_uri, e.g. /callback.html.

  5. This page runs a small script to:

    • Extract the code and state

    • postMessage back to the opener window

    • Close itself

  6. The SDK in the main window receives the message and completes the flow.

<!-- 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>

🚫 Safari Pitfall: Async Checks Block Popups

Let’s say you’re doing this:

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

This does not work in Safari Private. Why? Because the popup is no longer part of the direct click event stack.

✅ The Fix

Preload the incognito state before the login:

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

This way, you keep loginWithPopup() in the synchronous call path of the click event.


🌀 Dealing with Missing Query Parameters in Safari Private Mode

One of the more elusive bugs in Safari Private Mode is this:

After being redirected from Auth0 back to your app with ?code=...&state=..., window.location.search might be empty — even though the address bar shows the query string.

This is due to Safari’s privacy-preserving cross-origin navigation handling.

✅ The Fix: Force a reload to "unlock" the query string

To recover from this, we reload the page once, and cache that we've done so in sessionStorage:

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 should run as early as possible in your app (ideally before anything tries to call handleRedirectCallback()).


🔍 Detecting Safari Incognito Mode (Reliably)

We use detectincognitojs, which handles browser quirks well — but it's asynchronous.

Caveat:

Since detectIncognito() is async, you must run it before login is triggered — as shown above.

Historical Changes in iOS:

  • iOS 14–15: Limited cookies + window.opener = null

  • iOS 16+: Further restrictions on third-party storage and window.open

  • iOS 17+: Storage isolation gets even tighter, but reliable detection libraries still work

So: cache the result on load. Don’t wait until click.


✅ Recommendations for a Reliable Login Flow on iOS Safari (Private)

Concern Recommendation
Storage not shared Prefer popup over redirect
Popup blocked if async Preload detection logic
Query params lost Avoid relying on redirect flow in incognito
window.opener may be null Fallback to loginWithRedirect() when detected
Incognito detection is async Call on page load, cache result
Knowing when to refresh Trigger window.location.reload() after user is available

🔚 Final Thoughts

iOS Safari’s Private Mode is privacy-focused — and that’s a good thing. But for developers, especially those handling cross-origin authentication, it’s a bit of a landmine.

If you’re using auth0-spa-js, your best bet is:

  • Use popup login by default

  • Use redirect login as fallback (e.g. in incognito)

  • Detect Safari quirks early, and structure your login flow to be synchronous

Your users won’t know how close they were to a broken experience — and that’s exactly the point. ✨