🕵️♀️ 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
, andquery 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 evencookies
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 — breakinghandleRedirectCallback()
.
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 breakspostMessage()
-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:
-
User clicks "Login".
-
Your app calls
auth0Client.loginWithPopup()
(must be in a direct click handler). -
The SDK opens a popup to the Auth0
/authorize
endpoint. -
After login, the popup is redirected to your
redirect_uri
, e.g./callback.html
. -
This page runs a small script to:
-
Extract the
code
andstate
-
postMessage
back to the opener window -
Close itself
-
-
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. ✨