The redirect that forgot where you were going
A user clicked straight to a recordings page with an expired session. They logged back in and landed on the dashboard, three clicks from where they started.
By Meet ModiA user with an expired session clicked a deep link straight to a specific class's recordings page. The app bounced them to a separate authentication service to log back in. They logged in fine. Then they landed on the dashboard's root page, not the recordings page they'd clicked toward.
They had to navigate back to the class, click into recordings again, a support ticket's worth of friction for something that should have been invisible.
The bug: the redirect out remembered nothing. The auth service has no reason to know what page a user originally wanted, it only knows how to log someone in and send them back to wherever it's told.
Take 1: redirect out, hope for the best
function requireAuth() {
const isAuthenticated = checkSession();
if (!isAuthenticated) {
window.location.href = `https://auth.example.com/login?returnTo=${encodeURIComponent('https://app.example.com/dashboard')}`;
}
}returnTo always points at the dashboard, because that's the one destination that's always valid to land on. It works. It just throws away wherever the user actually was, every single time, regardless of whether they came from the dashboard or three levels deep in a specific class.
The auth service isn't the problem here. It's doing exactly what it was told: log the user in, send them to returnTo. The information about the real destination was never passed to it, because I never captured it before redirecting away in the first place.
Take 2: stash the destination before you leave
Before redirecting to the auth service, I write the current path and query string to sessionStorage under a fixed key. sessionStorage survives the round trip to the auth service and back, since it's scoped to the browser tab, not to any single page load.
const RETURN_PATH_KEY = 'post-login-redirect';
function requireAuth() {
const isAuthenticated = checkSession();
if (!isAuthenticated) {
const destination = window.location.pathname + window.location.search;
sessionStorage.setItem(RETURN_PATH_KEY, destination);
window.location.href = `https://auth.example.com/login?returnTo=${encodeURIComponent('https://app.example.com/dashboard')}`;
}
}Then, in the component that handles post-login onboarding, the first thing that runs after the app regains control reads that key back and resumes navigation to wherever the user actually intended to go.
function PostLoginHandler() {
const router = useRouter();
useEffect(() => {
const destination = sessionStorage.getItem(RETURN_PATH_KEY);
sessionStorage.removeItem(RETURN_PATH_KEY);
router.replace(destination ?? '/dashboard');
}, []);
return <LoadingSpinner />;
}The key gets removed immediately after reading, not after the navigation resolves. A stale value sitting in sessionStorage from an earlier abandoned login attempt would otherwise redirect a completely unrelated future login to the wrong place.
What I should have done first
I should have treated "where does the user end up after login" as a first-class part of the auth flow instead of an afterthought bolted onto returnTo. The dashboard-as-default worked fine for every test login I did myself, because I always started from the dashboard. The bug only shows up for deep links, and deep links are exactly how real users arrive, from a shared link, a bookmark, a notification. Testing only the path I personally take through my own app is how this kind of gap survives to production.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.