Send people back where they were after login, safely

  • security
  • auth
  • react

I added a help center to a web app I’m building: a set of guides behind login, each with a search box and section anchors. The first time someone shared a link to a specific section, it went nowhere useful. A signed-out person clicked it, got bounced to the login page, signed in, and landed on the dashboard. The link they were actually sent was gone.

That is one of those bugs that sounds cosmetic and isn’t. If shared links don’t survive login, people stop sharing links, and every “where do I find that?” question turns into a screenshot and a paragraph of directions.

The fix is old and well known: remember where the person was trying to go, and send them there after sign-in. The part worth slowing down for is that a “return to” value is input you’re about to navigate to. Handle it carelessly and you’ve built an open redirect.

Carry the full destination, not just the path

The route guard is the one place that knows exactly what the person asked for, so it records the destination when it redirects to login. I use router state rather than a query string, so the value never shows up in the address bar, logs, or a Referer header.

function RequireAuth({ user, children }) {
	const { pathname, search, hash } = useLocation();
	if (user === false) {
		return <Navigate to="/login" state={{ returnTo: pathname + search + hash }} replace />;
	}
	return children;
}

Grab all three pieces. My first instinct was pathname only, and that drops the two things that make a shared help link useful: the ?q= search and the #section anchor. Someone sent to “the part about recurring schedules” should land on that part, not the top of a long page.

The replace matters too. Without it, the Back button from the login page takes the person to the protected URL, which immediately bounces them to login again.

Treat the return path as untrusted

After a successful login, the tempting line is navigate(state.returnTo). Router state mostly comes from your own code, but the same pattern usually grows a ?next= query parameter the first time someone needs it to survive a full page load or an email link. At that point anyone can craft a link to your real login page that hands a freshly signed-in user to a look-alike site. That is the classic open redirect, and it’s a phishing gift because the link starts on your domain.

So I validate the value as if it came from a stranger, and fall back to the normal landing page whenever it looks wrong:

const BASE = 'https://app.invalid';

export function loginDestination(value, fallback = '/dashboard') {
	if (typeof value !== 'string') return fallback;
	if (!value.startsWith('/') || value.startsWith('//')) return fallback;
	if (/[\\\u0000-\u0020]/.test(value)) return fallback;

	const url = new URL(value, BASE);
	if (url.origin !== BASE) return fallback;
	if (url.pathname === '/login') return fallback;

	return url.pathname + url.search + url.hash;
}

Each check is there for a specific trick:

  • It must start with a single /. That rules out https://evil.example and javascript: style values outright.
  • No // prefix. //evil.example/path is a protocol-relative URL. It starts with a slash, so a naive “is it relative?” check passes it, and the browser happily leaves your site.
  • No backslashes, spaces, or control characters. URL parsers treat \ like / for web URLs, and they silently strip tabs and newlines. So /\evil.example and /<newline>/evil.example both turn into //evil.example after parsing, even though the raw string passed the first two checks.
  • Parse it against a fake origin and compare. This is the check that does the real work. Instead of trying to out-think every encoding trick with string rules, let the same URL parser the browser uses resolve the value, and confirm it’s still on the origin you started with. The .invalid top-level domain is reserved, so it can never collide with a real host.
  • Never return to /login. Otherwise a stale or malicious value sends a signed-in user straight back to the form, which looks like login is broken.

Returning the rebuilt pathname + search + hash from the parsed URL, rather than the original string, means whatever comes out is exactly what the parser understood.

Test the attacks, not just the happy path

The tests I care most about are the rejections. A table of hostile inputs is cheap to write and catches regressions when someone “simplifies” the function later:

test('keeps search and anchors', () => {
	expect(loginDestination('/help?q=teams')).toBe('/help?q=teams');
	expect(loginDestination('/help/getting-started#step-5')).toBe('/help/getting-started#step-5');
});

test.each([
	undefined,
	null,
	{},
	'https://example.com',
	'//example.com',
	'/\\example.com',
	'/\n/example.com',
	'/login',
])('falls back for %s', (value) => {
	expect(loginDestination(value)).toBe('/dashboard');
});

I also ran one end-to-end check in a real browser: open a deep link while signed out, fill in the actual login form, and assert the final URL matches the original, anchor included. Unit tests prove the function is right. Only the browser run proves the guard, the router state, and the login page are actually wired together.

Tradeoffs I accepted

  • Router state doesn’t survive a full reload. If someone refreshes the login page, or signs in through a flow that leaves the app and comes back, the destination is lost and they get the dashboard. For in-app redirects that’s fine. If you need it to survive, put it in a query parameter or sessionStorage, and run the same validator on the way out.
  • Same-origin only. If you ever need to return to a different subdomain or app, an allowlist of known origins is the next step. I’d rather add that deliberately than loosen this check.
  • The fallback is silent. A rejected value just goes to the dashboard. I don’t show an error, because the person did nothing wrong, and an attacker learns nothing from it.

A deploy footnote

While chasing this, one signed-in user still bounced from help links to the dashboard after the fix shipped. Their browser was running the previous bundle, which didn’t have the help route at all. The cause was caching: the app’s HTML was cacheable, so the browser kept an old index.html pointing at old scripts. Hashed asset files can be cached forever, but the HTML that references them should be served with Cache-Control: no-cache so it’s revalidated on every navigation. Otherwise every routing fix ships to everyone except the people who already had the app open.

The checklist

  1. Record the full destination (path, query, and hash) in the route guard.
  2. Use replace on the redirect to login so Back doesn’t loop.
  3. Validate before navigating: single leading slash, no //, no backslashes or control characters, same origin after parsing, never the login page.
  4. Fall back quietly to the usual landing page.
  5. Test the hostile inputs and one real login in a browser.

It’s a dozen lines of code. The difference is whether a shared link is a convenience or a way to borrow your domain’s reputation.