DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two subdomains, one session, and the only part of a URL a server never sees

Munchable is one product with three front ends. The marketing site is Next.js on munchable.app. The app is Expo: a native Android build, and the same code served by react-native-web on app.munchable.app. Sign-up happens on the Next.js site, because that is where the pricing table and the sign-up form are.

So the moment a new account exists, there is a routing problem. The person is standing on the marketing site holding a session the marketing site barely needs, and the thing they came for lives somewhere else.

Our answer is a page with two doors on it, and a third door hidden behind one of them. You can see it for yourself: make an account at munchable.app/get-started and you land on /continue.

The chooser

You're in
How would you like to use Munchable?

  [ Continue in browser ]
          or
  [ Get the Android app ]
Enter fullscreen mode Exit fullscreen mode

Two doors, because there are genuinely two products and neither is a downgrade. "Continue in browser" sends you to the web app, already signed in. "Get the Android app" sends you to the Play Store.

The third door is the Premium offer, and it only appears on the store route. Tapping "Get the Android app" shows one upgrade screen first, then takes you to the store whichever button you press. The reason is ordering, not persuasion: the web checkout is a browser thing, and the chooser is the last moment the browser is where the person is standing. Someone who is already Premium never sees it. That check is one line, and it reads like one:

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

Handing a session across a subdomain

"Continue in browser" is the interesting one. munchable.app has a signed-in Supabase session. app.munchable.app needs one. Nobody should type a password twice to cross a dot.

Three ways to do that:

  1. A cookie on the parent domain. Set the session cookie on .munchable.app and both hosts read it. This works and it is the boring answer, but it widens the blast radius of the marketing site: every page on the public site, including the ones rendered at build time with no auth in sight, now sits inside the cookie scope of the app.
  2. A one-time code. Mint a short-lived code on the first host, redirect with ?code=, exchange it on the second. Correct, and it needs an endpoint, a store for the codes and an expiry policy.
  3. The fragment. Put the tokens after the #.

We use the fragment, for one specific property: the fragment is the only part of a URL a server never sees. It is not in the request line, so it is not in your access logs, not in your CDN logs, not in the reverse proxy in front of your app, and not in whatever log drain you forgot you enabled three months ago. A refresh token in a query string is a refresh token in a dozen log files.

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 return value matters more than it looks. There is no session to hand over when the tab has gone stale, and the honest response to that is not an error toast, it is the sign-in page:

const ok = await continueInBrowser();
if (!ok) {
  setBusy(false);
  router.replace('/get-started');
}
Enter fullscreen mode Exit fullscreen mode

The other end, and the part people forget

A fragment stays out of server logs. It does not stay out of the address bar, and it does not stay out of browser history. A URL with a live refresh token in it is one accidental "copy link and paste it in a group chat" away from being somebody else's session.

So the consumer strips it immediately, in the same function that establishes the session:

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

The thing we got for free by writing it this way is that the handoff consumer is also the OAuth consumer. A Supabase implicit flow returns #access_token and #refresh_token. A PKCE flow returns ?code=. Our own handoff returns #access_token and #refresh_token. That is three producers and one function, and the function cares about the shape of the URL rather than who sent it:

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

Called with a URL that carries nothing, it returns ok: false and no error, because a plain visit to the app is not a failed sign-in.

Two small things around the edges

The chooser is also the page every auth-gated route sends you through, so it takes a ?next= parameter, and a ?next= parameter is an open redirect waiting to happen. Ours accepts a same-origin relative path or nothing at all:

export function safeReturnPath(raw: string | null | undefined, fallback: string): string {
  if (!raw) return fallback;
  if (!raw.startsWith('/')) return fallback;
  if (raw.startsWith('//') || raw.startsWith('/\\')) return fallback;
  return raw;
}
Enter fullscreen mode Exit fullscreen mode

Three lines of rejection for one line of acceptance. The /\\ case is there because some browsers treat /\evil.example exactly like //evil.example.

And the price on the upgrade screen is resolved on the server, in the layout, rather than fetched in the component. A chooser that renders "£10" and then corrects itself to "€12" after hydration is a page that just told the visitor it is guessing. We wrote about the rest of that decision in We stopped converting our price at checkout.

Try it

  • munchable.app/get-started is the sign-up. Any email works, you get a six digit code.
  • The chooser is /continue, straight after.
  • Press "Continue in browser" and watch the address bar on app.munchable.app. The tokens are there for one paint and then they are not. Open devtools and look at the Network tab if you want to confirm nothing carrying them ever left the browser.

Top comments (0)