Our two apps hand a session to each other in a URL fragment, and the point is the server that never sees it
Munchable is two front ends and one account. There is a Next.js site on munchable.app, which is the marketing pages and sign-up, and there is the app itself, an Expo project that ships to Android and also builds for the
Munchable is two front ends and one account. There is a Next.js site on munchable.app, which is the marketing pages and sign-up, and there is the app itself, an Expo project that ships to Android and also builds for the web on app.munchable.app.
Two origins, which means the sign-up problem: somebody creates an account on the website with a six-digit email code, and then wants to use the app in the same browser. Cookies do not cross origins. Neither does local storage. The default outcome is that they log in twice in ninety seconds, and the second time they have to find the code email again.
The whole fix is 26 lines, and the only interesting character in it is #.
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;
}
Why the fragment and not the query string
A URL fragment is never sent to a server. Not in the request line, not in a header, not by a redirect, not by the CDN in front of either origin. The browser keeps it and hands it to the page.
Put the same two tokens in ?access_token=... and every one of those surfaces now has a refresh token in it: the access log on the receiving deploy, the platform's request log, any error reporter that captures the current URL, and the Referer header on the next outbound request from that page. None of those are breaches on their own. All of them are places where a long-lived credential sits in plain text in a system nobody thinks of as a credential store, which is precisely how credentials leak.
So the rule is simple enough to state as a rule. A secret in a URL goes after the hash or it does not go in a URL.
Returning false is the other half
The function returns a boolean, and the caller cares:
const onContinueWeb = async () => {
setBusy(true);
const ok = await continueInBrowser();
if (!ok) {
setBusy(false);
router.replace('/get-started');
}
// On success the tab is navigating to the web app; leave `busy` set.
};
There is no session to hand over if the one in this tab has expired between loading the page and pressing the button. Navigating anyway would land the user on the app with an empty fragment, where they would be asked to sign in by a screen that has no idea they just tried. Returning false sends them back to sign-in on the origin they are already on, with the state they are already in.
The comment on the success path is the sort of thing worth writing down. On success you must not clear the loading state, because the tab is in the middle of a cross-origin navigation and a button that springs back to life invites a second press.
One consumer, two producers
The receiving side does not know or care that the website sent it. It has one function for "this URL carries a session", and two things feed it: the handoff, and an OAuth provider return, which may arrive as an implicit fragment or as a PKCE ?code=.
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 || '');
}
Fragment first, query second, and the query is cut at a # so a URL carrying both does not produce a parameter with half a fragment glued to it. Then one branch per shape, setSession for a token pair and exchangeCodeForSession for a code, and when neither is present it returns a result rather than throwing, surfacing error_description if the provider sent one. A function that handles "nothing to consume" quietly is a function you can call on every cold start without guarding it.
Then it cleans up after itself
if (result.ok && Platform.OS === 'web' && window.history?.replaceState) {
window.history.replaceState(null, '', window.location.origin + window.location.pathname);
}
The fragment has done its job the moment setSession resolves, and from then on it is a liability: in the address bar, in the back-button history, and in whatever gets pasted into a chat window when somebody wants to show a friend the app. replaceState with the bare path removes it without adding a history entry.
Worth being honest about what this does and does not achieve. The tokens were in the address bar for a moment, they are in the page's memory for as long as the session lasts, and a browser extension with host permissions could have read the URL while it was there. What the fragment plus the strip does buy is total: no server, CDN, proxy or log file on either side of the handoff has ever held the refresh token, and nothing in the browser's own history does either. The residual risk is on the device, where the session has to live anyway.
The four lines next door
Sign-up is one page that two kinds of visitor reach, so it takes a destination:
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;
}
Four checks for the one bug this parameter always has. An absolute URL is rejected, obviously. //evil.example is the one people miss: it starts with a slash, it passes a naive test, and browsers read it as protocol-relative, so router.replace('//evil.example') leaves your site. /\evil.example is the same attack through a quirk several browsers share. Everything else is a same-origin path, and the fallback is the ordinary destination rather than an error, because a visitor who followed a mangled link should still end up signed in somewhere sensible.
One more Next.js specific choice sits above it:
// `next` is read from `window.location` in an effect rather than through
// `useSearchParams`, which would force a Suspense boundary around a page that
// is otherwise fine to prerender.
useSearchParams opts a route into dynamic rendering unless it is wrapped in Suspense. Reading the same value out of window.location in an effect keeps the sign-up page prerendered and static, which matters because it is the page every advert points at. The cost is one render without the parameter, which nobody sees because the parameter only changes where the page sends you after you have typed an email address.
What the user experiences
They type an email, get a code, type it, and land on a chooser with two doors: continue in the browser, or get the Android app. Pressing the first one does the handoff above and they arrive in the app signed in, with no second code. Pressing the second one offers Premium once, because that press happens in a browser where Stripe is allowed, and then sends them to the store. Already Premium, and the offer is skipped entirely.
You can walk up to the front of it on the live site: the sign-up page is the entry, and the small print under the form links the terms and the privacy notice, which is where the claim on that page ("we store only your email and Premium status") is spelled out properly. The about page explains why there are two front ends at all.
One account, two origins, 26 lines, and a credential that has never been written to a log because of a character that stops it being sent.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.