🕵️♀️ 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,sessionStorageand cookies don't always survive cross-domain hops. Auth0's hosted pages at*.auth0.comrely 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=..., butwindow.location.searchcomes back empty. Fun one to debug. - Popups are only allowed if they're triggered synchronously from a user interaction. Any
awaitor.then(...)beforewindow.openand the popup blocker eats it. window.openeris sometimesnullfor privacy reasons, which breaks thepostMessagechannel 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:
- User clicks "Login".
- Your code calls
auth0Client.loginWithPopup()— and this has to happen directly inside the click handler, no awaits in between. - The SDK opens a popup pointing at
/authorizeon Auth0. - After login the popup is redirected to your
redirect_uri, typically something like/callback.html. - That page grabs
codeandstate,postMessages them to the opener, and closes itself. - 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 tologinWithRedirect()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.