DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A session reaches our app four different ways, and detectSessionInUrl is off so one function can own all of them

Munchable is two deployed things on two origins. The marketing site and everything with a URL worth indexing is a Next.js app at munchable.app: the conditions pages, the ingredient answers, the recipes, sign-in. The app itself is an Expo app, shipped to Android and also served on the web through react-native-web, at app.munchable.app.

Two origins means two session stores, which means somebody has to carry a sign-in across. There are four ways a session arrives in the Expo app, and exactly one function consumes all of them.

Why the stores cannot just be shared

The Next.js side keeps its session in cookies, read by a server client on every request. The Expo app keeps its own:

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: Platform.OS === 'web' ? undefined : SecureStoreAdapter,
    autoRefreshToken: true,
    persistSession: true,
    // We complete OAuth ourselves via WebBrowser + setSession; do not let the
    // client try to parse the session out of a browser URL.
    detectSessionInUrl: false,
  },
});
Enter fullscreen mode Exit fullscreen mode

On a phone that is expo-secure-store. On the web it is the library default, which is localStorage, and localStorage is per origin. A cookie on the parent domain would be visible to both, and it still would not be where the Expo app's auth client looks for a session. So "share a cookie" does not solve this; something has to put a session into the app's own storage.

The last line is the one that shapes everything below. detectSessionInUrl defaults to true, and supabase-js will happily pull tokens out of the address bar for you. It is switched off, which means every arrival path is explicit and lives in one place.

Producer one: our own site hands the session over

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

Tokens in the URL, which deserves the flinch it gets, so here is the reasoning in full:

 * 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.
Enter fullscreen mode Exit fullscreen mode

A fragment is the one part of a URL that browsers do not send. Not in the request line, so not in access logs, not in a proxy's logs, not in the Referer header of the next request. Put the same tokens in a query string and they are written to disk on at least two machines before anything reads them.

Then the receiving side removes it:

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

That closes the two holes a fragment still has: browser history, and the URL somebody copies out of the address bar to send to a friend.

Producers two, three and four

The same consumer handles OAuth returns, and it accepts both shapes because different platforms produce different ones:

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({ ... });
Enter fullscreen mode Exit fullscreen mode
  } else if (code) {
    const { error } = await supabase.auth.exchangeCodeForSession(code);
Enter fullscreen mode Exit fullscreen mode

The parser does not care which part of the URL carried the parameters:

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

Fragment first, query second, because the implicit flow returns tokens in a fragment and PKCE returns a code in a query, and the app is on the receiving end of both depending on how sign-in started.

On the web, Google sign-in is a full-page redirect rather than a popup:

// Web (react-native-web): a popup is unreliable (blockers, COOP), so do a
// full-page redirect. supabase-js navigates the tab to Google; on return
// the implicit `#access_token` fragment is consumed by init().
Enter fullscreen mode Exit fullscreen mode

COOP is the part people hit and cannot diagnose: cross-origin isolation headers cut the opener relationship a popup flow depends on, and the failure arrives as a window that signs in successfully and then cannot tell anybody.

On native it is a system auth browser and a code exchange:

const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo);
if (result.type !== 'success' || !result.url) {
  // User dismissed or cancelled, not an error to surface loudly.
  return {};
}
Enter fullscreen mode Exit fullscreen mode

A dismissed sheet returns the same way a failure does, and treating "changed their mind" as an error is how an app ends up showing a red banner to somebody who simply tapped Cancel.

And the fourth producer skips URLs entirely:

// Native Apple flow returns an identity token, which Supabase verifies
// directly (no browser round trip). Name/email are only shared on first
// consent; we never attach the user's conditions to the account.
const credential = await AppleAuthentication.signInAsync({ ... });
Enter fullscreen mode Exit fullscreen mode

Four producers, one consumer, one place to look when a sign-in does not stick.

Consume before you restore

The ordering in the auth store is load-bearing:

// On web, a session can arrive in the URL: the landing 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

Restore first and you read an empty store, set restored: true, and the gate renders sign-in for the few hundred milliseconds it takes the tokens in the URL to be accepted. The user watches a sign-in screen appear and vanish immediately after signing in, which reads as a bug even though the outcome is correct.

urlHasSession exists so this branch is cheap and obvious:

export function urlHasSession(url: string): boolean {
  const params = paramsFromUrl(url);
  return Boolean(params.get('access_token') || params.get('code'));
}
Enter fullscreen mode Exit fullscreen mode

The listener's rule: never downgrade

This is the most defensive code in the file. Only an explicit sign-out is allowed to clear the account flag, and the comment above it names the symptom: every account gate in the app redirects to sign-in the instant that flag is false, so a background token refresh that momentarily flips the session to null would yank a mid-scan user onto the sign-in screen.

if (event === 'SIGNED_OUT') {
  set({ session: null, user: null, account: false });
  ...
}
set({
  session,
  user: session?.user ?? null,
  account: session ? isAccount(session) : get().account,
});
Enter fullscreen mode Exit fullscreen mode

The last line is the rule: with no session in the event, keep the account we had rather than downgrading.

onAuthStateChange is a stream of events, not a stream of truths about whether somebody is signed in. A token refresh can emit with a null session mid-flight. If every gate in the app treats account === false as "throw them out", then a momentary null is a user losing a scan they were halfway through in a supermarket aisle.

So exactly one event is allowed to mean signed out, and the general case keeps the account it had. It is the same shape as the absent-field problem one layer up: a missing value is not a negative answer.

One side effect does fire on a fresh sign-in:

if (event === 'SIGNED_IN' && isAccount(session)) {
  void apiFetch('/api/consent', { method: 'POST' }).catch(() => {});
}
Enter fullscreen mode Exit fullscreen mode

Recording acceptance of the current terms, fire and forget, because failing to record consent must not fail a sign-in.

The anonymous sessions that used to be here

// Restore only a real signed-in account. We no longer mint anonymous
// sessions: scanning requires an account so scans-left and Premium can be
// checked against the server.
Enter fullscreen mode Exit fullscreen mode

The app used to sign people in anonymously so a first scan worked before anybody typed an email, which is lovely and which cannot coexist with a per-account allowance. A quota on a credential anybody can mint for free is not a quota. The cost of removing it is a sign-in before the first scan, and the public pages absorb some of that: an answer page answers one question about one ingredient without an account at all.

Anonymous sessions still exist in the type system, and the gates treat them as a distinct third state rather than as signed out. The admin gate rejects them explicitly, for the reason that made them unusable as a quota: an anonymous id is free to mint and carries no verified address.

Where the handoff is actually offered

After sign-up there is a chooser, and the comment on it has a constraint in it that is not technical:

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

"Browser context, so Stripe is allowed" is the whole of app store payment policy compressed into nine words. The moment to offer a subscription with your own payment processor is while the person is still in a browser, and once they are inside the installed app that option is gone. So the chooser is not just a convenience, it is the one place in the funnel where that offer can legally be made.

If you are signed out, that page bounces you to sign-in from the client, after a brief loading state, because the entitlement and the currency it prints both need to know who you are first.

Top comments (0)