DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The session rides in the URL fragment: handing a login from the marketing site to the app

Munchable is two deployed things on two origins. The marketing and content site is a Next.js app at munchable.app. The product is an Expo app, shipped to Android and also served on the web through react-native-web at app.munchable.app. A person signs up on the first and uses the second, and the sign-up should not be followed by a sign-in.

Here is how the session crosses that boundary, what makes it reasonably safe, and what it deliberately does not do.

The producer

Sign-up on the site is a six-digit email code or Google, through Supabase. Once there is a session, the post-signup page offers two routes, "Continue in browser" and "Get the Android app". The first one is this function, in full:

/**
 * Hand the signed-in Supabase session to the web app so the user arrives
 * already signed in, with no second login. The tokens ride in the URL
 * fragment, which never reaches a server (so the refresh token stays out of
 * request logs); the web app consumes them with `setSession` and immediately
 * strips them from its address bar.
 */
export async function continueInBrowser(): Promise<boolean> {
  const supabase = createClient();
  const { data } = await supabase.auth.getSession();
  const session = data.session;
  if (!session?.access_token || !session?.refresh_token) return false;

  const fragment = new URLSearchParams({
    access_token: session.access_token,
    refresh_token: session.refresh_token,
  }).toString();
  window.location.assign(`${WEB_APP_URL}/#${fragment}`);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The target is a compile-time constant. There is no redirect parameter, so there is no attacker-chosen destination. The store buttons on the landing page use the same function: a signed-in visitor who clicks "Open the web app" is handed straight in, and a signed-out one goes to sign-up first.

The consumer

The Expo app has one consumer for two producers, because an OAuth provider return arrives in the same shape:

export async function consumeSessionFromUrl(url: string): Promise<ConsumeResult> {
  const params = paramsFromUrl(url);
  const accessToken = params.get('access_token');
  const refreshToken = params.get('refresh_token');
  const code = params.get('code');

  let result: ConsumeResult;
  if (accessToken && refreshToken) {
    const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
    result = error ? { ok: false, error: error.message } : { ok: true };
  } else if (code) {
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    result = error ? { ok: false, error: error.message } : { ok: true };
  } else {
    return { ok: false, error: params.get('error_description') ?? undefined };
  }

  if (result.ok && Platform.OS === 'web' && window.history?.replaceState) {
    window.history.replaceState(null, '', window.location.origin + window.location.pathname);
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The replaceState call is the second half of the safety argument. The fragment never reached a server, and after this line it is not in browser history and not in a URL anyone might copy.

The ordering bug that was not a bug yet

The app's account gate renders nothing until the stored session has been restored, then redirects to sign-in the instant there is no account. If the URL is consumed after restoration, there is a frame where the gate sees no account and bounces the user to the sign-in screen they just came from. So the boot sequence consumes first:

// On web, a session can arrive in the URL: the landing to web-app handoff, or
// a Google full-page redirect return. Consume it BEFORE restoring so the
// account gate (which renders null until `restored`) never flashes sign-in.
if (Platform.OS === 'web' && urlHasSession(window.location.href)) {
  void consumeSessionFromUrl(window.location.href).finally(finishRestore);
} else {
  finishRestore();
}
Enter fullscreen mode Exit fullscreen mode

A related rule in the same file: only an explicit sign-out or an invalidated session clears the account. A background token refresh momentarily reporting a null session must never downgrade it, or a mid-scan user gets yanked onto the sign-in screen.

What this deliberately is not

It is not a one-time code exchange. There is no server-side handoff record, no single-use flag, no hashed token and no custom TTL. The user's own live refresh token crosses the boundary in the URL fragment, and the mitigations are transport shape and lifetime in the address bar, not token exchange.

That is a considered trade. Both origins are ours, the destination is fixed, the fragment is never sent to a server, and the token's lifetime is Supabase's own rotation. A code exchange would add a table, a route and a second network round trip to remove a risk that mostly exists if someone can already read the user's address bar during a single navigation. If that assessment changes, the consumer already accepts a code parameter, so the swap is on the producer side only.

What is enforced elsewhere: any next parameter on the sign-in and OAuth callback routes is validated as a same-origin path, so neither can become an open redirect, and the web app on its subdomain is marked noindex, nofollow.

The sign-in itself

The email path is a code, not a magic link:

const { error } = await supabase.auth.signInWithOtp({
  email: email.trim(),
  options: { shouldCreateUser: true },
});
Enter fullscreen mode Exit fullscreen mode

No emailRedirectTo, so nothing about sign-in depends on a link opening in the right browser, and shouldCreateUser makes a first sign-in create the account implicitly. There is no signup page. The code input is inputMode="numeric" with autoComplete="one-time-code", so phones offer the code from the message.

Try it

Go to munchable.app/get-started, sign in with an email code, and on the next page choose "Continue in browser". Watch the address bar: you land on the app's origin, the fragment is there for one paint, and then it is gone. The app opens signed in with no second prompt.

Top comments (0)