OAuth & OIDC

OAuth Popup vs Redirect: Which Flow Should You Use?

A practical comparison of popup-based and full-redirect OAuth flows — UX, state preservation, mobile and browser quirks, security trade-offs, and how each behaves inside an embedded iframe.

Emilian GheoneaJune 27, 20266 min read

When you add "Sign in with Google" to an app, you hit a fork almost immediately: should the OAuth flow happen in a popup window, or should it redirect the whole page away and back? Both are common, both work, and each has real trade-offs. This article compares them honestly so you can pick deliberately rather than by accident.

Both approaches run the same underlying Authorization Code flow with PKCE — they differ only in where the provider's login screen is shown and how control returns to your app.

The redirect flow

The classic approach: your app sends the entire browser tab to the provider's authorization URL. The user authenticates on the provider's domain, and the provider redirects the whole tab back to your redirect_uri with an authorization code.

Rendering diagram…

Pros

  • Simplest and most robust. No popup blockers, no cross-window messaging, no window.opener edge cases.
  • Works everywhere, including mobile browsers and in-app webviews where popups are unreliable or forbidden.
  • Best security posture — it's the flow OAuth providers optimize and test for.

Cons

  • You leave your app. Any unsaved in-memory state is lost unless you persist it before redirecting.
  • Perceived as heavier — the whole page navigates away and reloads.

The popup flow

Here your app opens a small popup window pointed at the provider's authorization URL. The main page stays exactly where it is. When the popup completes, it passes the result back to the opener (via postMessage) and closes.

Rendering diagram…

Pros

  • Preserves page state. The main app never unloads, so complex in-progress state (a half-filled form, a live editor) survives untouched.
  • Feels lighter — a focused modal-like experience.

Cons

  • Popup blockers. The window must be opened in direct response to a user click, or browsers suppress it.
  • Cross-window messaging complexity — you must handle postMessage, verify the origin, and cope with the user closing the popup manually.
  • Mobile is shaky. On many mobile browsers popups behave poorly or open as new tabs, undermining the UX benefit.
  • Privacy features interfere. Storage partitioning and popup restrictions increasingly complicate cross-window flows.

What the popup handoff actually costs you

The bullet list above undersells how much code the popup flow needs before it is production-safe. A correct implementation has to answer four questions that the redirect flow never asks.

How does the popup talk back? The popup lands on your callback route on your origin, so it can reach the opener via window.opener.postMessage. That call must be pinned to an exact target origin — passing '*' broadcasts the authorization result to whatever page happens to be the opener, which is a real leak if your app is ever framed. On the receiving side you check event.origin before touching event.data, and you check it against a constant, not against something derived from the message itself.

How do you know the user gave up? Users close popups. Nothing fires an event you can listen to from the opener, so you poll popup.closed on an interval and treat the transition as a cancellation. Forget this and your UI sits on a spinner forever. The polling loop also has to be cleared when the flow succeeds, or you leak a timer per attempt.

What happens when the popup is blocked? window.open returns null, and it does so silently. Every popup implementation needs a redirect implementation behind it as a fallback, which means you write and maintain both paths anyway.

What happens when the opener navigates away? If the user clicks something in the main window while the popup is open, window.opener may be gone by the time the popup tries to report back. The popup then has to fall back to persisting the result somewhere the main app can find it, or simply restart the flow.

const popup = window.open(authorizeUrl, 'oauth', 'width=480,height=720');
if (!popup) {
  // Blocked. Fall back to the redirect flow — do not just show an error.
  location.assign(authorizeUrl);
  return;
}

const timer = setInterval(() => {
  if (popup.closed) {
    clearInterval(timer);
    onCancelled();
  }
}, 500);

window.addEventListener('message', function handler(event) {
  if (event.origin !== window.location.origin) return; // constant, not event-derived
  if (event.data?.type !== 'oauth-result') return;
  clearInterval(timer);
  window.removeEventListener('message', handler);
  popup.close();
  onResult(event.data);
});

That is the happy version. It is not enormous, but it is four failure modes of ongoing maintenance that the redirect flow simply does not have.

The third option nobody lists: the system browser

On native mobile there is a genuinely different answer, and teams that only consider "popup or redirect" tend to miss it. An embedded webview is a hostile place to run OAuth: it does not share the system browser's cookie jar, so the user has to type their Google password again even though they are signed in everywhere else, and providers increasingly refuse to serve their login pages to webviews they can fingerprint as embedded. Google has blocked OAuth in embedded webviews for years precisely because the host app can read the keystrokes.

The correct pattern is a custom tab — SFSafariViewController on iOS, Chrome Custom Tabs on Android — which is a redirect flow rendered in a browser the host app cannot instrument, returning to the app via a claimed https link or a custom scheme. If your product has a mobile app, this is the third flow you will end up supporting regardless of what you pick on the web.

Failure modes you will actually hit

Whichever flow you choose, these are the tickets that show up:

  • redirect_uri mismatch. Providers match this string exactly, including the trailing slash and the port. A staging environment on a preview URL will fail until that exact URL is registered. This is the single most common OAuth support ticket in existence.
  • State lost on iOS Safari. Aggressive tab eviction on low-memory devices can discard sessionStorage between the redirect out and the redirect back. If your flow depends entirely on client-side persistence, some percentage of mobile users will bounce back to a broken callback. Storing the correlation server-side, keyed by the state value, avoids this.
  • The double-callback. Users refresh the callback URL, or a link prefetcher hits it. Authorization codes are single-use, so the second exchange fails with an opaque provider error. Handle it by making the callback idempotent — if a session already exists for that state, treat the request as a success instead of an error.
  • Back-button purgatory. After a redirect flow completes, the provider's authorization URL is still in history. Pressing back sends the user to a consumed code. history.replaceState on the callback, rather than a push, keeps that page out of the stack.
  • Popup opened outside a gesture. Opening the popup inside a promise callback — after an await on a token fetch, for instance — loses the user-activation window and gets it blocked. Open the popup synchronously in the click handler and navigate it afterwards.

Head to head

RedirectPopup
Page state preservedNo (persist first)Yes
Popup-blocker riskNoneReal — needs a user gesture
Mobile reliabilityExcellentInconsistent
Implementation complexityLowHigher (windowing + messaging)
Perceived weightHeavierLighter
Provider support/testingBestGood, more edge cases

Preserving state across a redirect

The main objection to redirects — losing state — is usually solvable. Before redirecting, persist what you need and restore it on the callback:

// Before redirecting to the provider
sessionStorage.setItem('returnTo', location.pathname);
sessionStorage.setItem('draft', JSON.stringify(formState));

// On your callback route, after exchanging the code
const returnTo = sessionStorage.getItem('returnTo') || '/';
restoreDraft(JSON.parse(sessionStorage.getItem('draft') || '{}'));
history.replaceState(null, '', returnTo);

Encode a state parameter (which you need anyway for CSRF protection) to correlate the callback with the request that started it.

What about inside an iframe?

Embedded auth adds a wrinkle: OAuth providers routinely refuse to render their login pages inside an iframe (via X-Frame-Options / frame-ancestors) to prevent clickjacking. So an embedded auth component cannot simply load the provider in the same frame. The practical answer is to break out of the frame — a popup or a top-level redirect — for the provider step, then deliver the result back to the embed. This is one of several reasons iframe authentication is genuinely difficult, and it's exactly the kind of plumbing EmbedAuth handles so your page just receives a finished, verified sign-in.

A recommendation

For most products, default to the redirect flow. It's simpler, more reliable, mobile-friendly, and has the strongest security track record — and the state-loss objection is a few lines of persistence code. Reach for the popup flow only when preserving heavy in-page state genuinely matters and your users are predominantly on desktop browsers where popups behave.

Whichever you choose, the security fundamentals are identical: use the Authorization Code flow with PKCE, validate state, exact-match your redirect_uri, and verify the resulting tokens on your server.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.