DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A signed-in session crosses two of our domains in a URL fragment, and one consumer handles both producers

Munchable has two web origins. munchable.app is a Next.js site: the marketing pages, the ingredient guides, sign-in, checkout. app.munchable.app is the actual app, which is the Expo app built for web with react-native-web. Different origins, so nothing in browser storage on one is visible to the other.

Which creates a specific moment. Somebody has just created an account on the first origin. The app they came for is on the second one. Asking them to log in again, forty seconds after logging in, is the kind of small insult that loses a user who was already unsure.

So the session gets handed over. Here is the whole mechanism, both ends, and the product decision sitting next to it that has nothing to do with auth.

The producer is eleven lines

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 important character in that template literal is #.

A URL fragment is never sent to the server. Not to ours, not to a CDN in front of ours, not into an access log, not into the Referer of a later request. Put the same two values in the query string and the refresh token is in a web server log, probably in a log aggregator, and quite possibly in whatever analytics you forgot you installed. In the fragment it exists only in the browser's address bar and in whatever JavaScript on that page reads it.

Returning false rather than throwing when there is no session matters for the caller: the chooser treats it as "you are not actually signed in" and redirects to sign-in, which is the correct response to a session that evaporated while somebody was reading the page.

The consumer has two producers, so it has one entry point

On the app side the same fragment arrives, and so does something else: the return leg of an OAuth sign-in, which can be an implicit #access_token pair or a PKCE ?code=. Three shapes, one function:

/** Extract OAuth tokens/code from a redirect URL (fragment or query). */
export function paramsFromUrl(url: string): URLSearchParams {
  const hash = url.includes('#') ? url.split('#')[1] : '';
  const query = url.includes('?') ? url.split('?')[1]?.split('#')[0] : '';
  return new URLSearchParams(hash || query || '');
}
Enter fullscreen mode Exit fullscreen mode

hash || query is deliberate precedence: when a URL somehow carries both, the fragment is the one that did not travel through a server, so it wins. And the query is split on # first, because ?code=abc#junk would otherwise hand you a code with junk glued to the end of it.

Then the branch:

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 {
  // Nothing to consume. Surface any provider error message if present.
  return { ok: false, error: params.get('error_description') ?? undefined };
}
Enter fullscreen mode Exit fullscreen mode

That last branch is the one people skip. A URL with no tokens and no code is not necessarily a mistake: it is what a provider sends you when the user cancelled, or when the app is misconfigured, and the only useful thing in it is error_description. Reading it is the difference between "could not sign in" and "the provider says this redirect URI is not registered".

Cleaning up after yourself

A session in the address bar is a session in the browser's history, and in whatever a person pastes into a chat window when something goes wrong.

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

replaceState rather than pushState, so the URL with the tokens in it is not an entry somebody can press Back to. Guarded on Platform.OS === 'web' because this same function runs in the native app, where the URL came from a deep link and there is no address bar to clean. Guarded on typeof window because the module can be imported during a static web build.

Worth being straight about the tradeoff: a refresh token in a fragment, even for one navigation, is a weaker design than handing over a single-use code that the receiving origin exchanges server-side. The fragment never hits a log, the receiving page strips it immediately, and the window is one navigation long, which is why it is acceptable today. A one-time handoff code is the upgrade, and the reason to prefer it is not logs, it is that a code can be made single-use and expiring, and a refresh token cannot be either of those things.

The part that is not about auth

The page doing the handoff is the post-signup chooser, and it has a second job that decides the order of everything on it.

/**
 * Post-signup chooser. The user has an account; now they pick a route:
 *  - "Continue in browser" → hand the session to the web app (already signed in).
 *  - "Get the Android app"  → first offer Premium (browser context, so Stripe is
 *    allowed), then send them to the Play Store.
 */
Enter fullscreen mode Exit fullscreen mode

Read that second bullet again, because the parenthesis is the whole reason the flow has two steps. Inside the Android app, Munchable may not show a button that takes you to Stripe. Google's anti-steering rules are explicit, and the app honours them with a platform branch and no cleverness:

{Platform.OS === 'web' ? (
  // In a browser the user can pay directly with Stripe. Native builds must
  // NOT show a checkout button/link (Apple/Google anti-steering); they
  // get the informational note below instead.
  <Button label="Get Premium" variant="gold" onPress={onGetPremium} />
) : (
  /* a sentence, no link */
)}
Enter fullscreen mode Exit fullscreen mode

So the browser is not merely a convenient place to offer a subscription. It is the only place this product can offer one with a button on it. Once somebody taps through to the Play Store, that affordance is gone until they come back to a browser, which most people never deliberately do.

Hence the ordering: tapping "Get the Android app" does not navigate to the store. It switches the card to a Premium step with two ways out, "Upgrade to Premium" and "Not now, take me to the app", and the second one is a plain button rather than a greyed-out afterthought. One offer, at the one moment it can be made, and a decline that costs one tap.

With one check in front of it:

const onDownload = () => {
  // Already Premium: no upsell, straight to the store.
  if (isPremium) {
    goPlay();
    return;
  }
  setPhase('premium');
};
Enter fullscreen mode Exit fullscreen mode

Showing a subscription offer to somebody who is already subscribed is worse than showing nothing. It tells them you are not keeping track.

Two small things in the layout

The page is a client component, which makes two server concerns awkward, and both are solved by the layout wrapping exactly one page:

export const metadata: Metadata = pageMetadata({
  title: 'Continue',
  description: 'Choose how to open Munchable.',
  path: '/continue',
  noIndex: true,
});

export default async function ContinueLayout({ children }) {
  const currency = await getDisplayCurrency();
  return <DisplayCurrencyProvider currency={currency}>{children}</DisplayCurrencyProvider>;
}
Enter fullscreen mode Exit fullscreen mode

noIndex because a signed-out crawler sees an empty shell, and an indexed empty shell is a thin duplicate of the sign-in page competing with it in search results.

The currency is resolved on the server so the Premium step prints the right figure on first paint. A price that renders in pounds and then corrects itself to euros after hydration is the single most expensive flicker in a signup flow. Resolving it server-side normally costs you cacheability, and here it costs nothing, because the page is already noindex and never cached.

Look at it yourself

  • munchable.app is the origin where an account is created, and the pricing table at the bottom is the figure the chooser's Premium step quotes. Signing up there lands you on the chooser described above.
  • app.munchable.app is the other origin, the Expo build, and the one that receives the fragment. Opening it directly shows you the sign-in it would have asked for if the handoff did not exist.
  • munchable.app/terms and munchable.app/privacy are the two pages that have to agree with all of the above: what a subscription is, and what we hold. The privacy notice names the web app at app.munchable.app explicitly, because a notice that only mentions one of your two origins is not describing your product.

Top comments (0)