Login Redirects and the Open-Redirect Trap
Send the user back to what they were trying to reach — and never redirect to a URL somebody handed you in a query parameter.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I return a user to where they were going after logging in, without turning my login page into a redirector for anywhere on the internet?
Someone clicks a link to a specific document, is asked to sign in, signs in, and expects to land on that document. Not the home page, not a dashboard, not a second search for the thing they already had a link to.
Put the destination in the URL — /login?next=/documents/42 — and after a successful sign-in do location.assign(params.get('next')). It is two lines and it puts the user exactly where they wanted to be.
next is user input, and location.assign will happily navigate anywhere. /login?next=https://evil.example/login sends the user off your origin, onto a page that looks exactly like yours, asking them to sign in again — and they will, because your site sent them there (Clickjacking and Framing).
nextis user input, andlocation.assignwill happily navigate anywhere./login?next=https://evil.example/loginsends the user off your origin, onto a page that looks exactly like yours, asking them to sign in again — and they will, because your site sent them there (Clickjacking and Framing).- The scheme is user input too.
javascript:in anextparameter is script execution in your origin if it reaches a navigation or anhref, and protocol-relative//evil.exampleis an absolute URL that does not look like one (Cross-Site Scripting). - Losing the destination is the everyday failure and it is silently expensive: an email link to a specific record, an expired session on a deep page, a shared link opened in a fresh browser — all of them dump the user at a dashboard with no explanation.
- Under client-side routing there may be no navigation to hang the destination on. The router replaces the view, so if nothing captured the intended path before the swap it is simply gone (Client-Side Routing).
- The naive version also loses everything that was not in the path: query parameters, filters, scroll position, and any state the URL was carrying (The URL Is Application State).
What is actually happening
In the browser, not in the framework.
- An open redirect is an endpoint on your origin that will send a visitor to an attacker-chosen destination. The value is that the link starts on your domain: it passes a glance, survives link previews, and inherits whatever trust your domain has.
- Your origin is also the referrer for the destination, and anything in the URL — including a token somebody unwisely placed there — can travel with it. Redirects leak more than the navigation itself (The URL Is Application State).
- The defence is an allowlist of internal destinations, not a denylist of bad ones. Parse the candidate with
new URL(candidate, location.origin)and require that the result's origin equals your own; then check the path against what your router actually knows. - String checks are the trap.
startsWith('/')accepts//evil.example, which the browser reads as protocol-relative and absolute.includes('example.com')acceptshttps://example.com.evil.test. Only parsing gets these right (Route Matching). - A destination that is a path is safer than a destination that is a URL. Storing only a path, and reconstructing the URL from your own origin at redirect time, removes the entire class of problem by making an external destination unrepresentable.
- After the redirect, the login view is gone and the app has swapped context. Nothing about that transition is announced or focus-managed by default in a single-page application — a browser navigation at least resets focus to the document, and a router-driven view change does not (History and Navigation).
What this makes the browser do
And which of it is avoidable.
- A full navigation discards the document: memory, in-flight requests, unsaved state, all of it. That is why in-place re-authentication is preferable when the session merely expired (Session Expiry and the Refresh Race).
- Each redirect hop is a round trip before anything renders. A chain of "go to login, then to the destination, then to a canonical URL" is three of them on a cold connection (Reading a Network Waterfall).
- A client-side route change reuses the document, so nothing is reset by default — not focus, not scroll, not the document title. Every one of those is now your responsibility (Scroll Restoration).
- Prefetching the intended destination while the user is still on the login form wastes bandwidth if they fail to sign in, and fetches a permission-scoped resource for someone who is not yet authenticated (Route Loading Boundaries).
Keeping the destination through the whole detour
The user was going somewhere. Everything in this flow is bookkeeping around that fact, and the destination has to survive an interruption, possibly a full navigation, possibly a round trip through an identity provider, and a return. Each hop is a place it gets dropped.
Capture it at the moment of interruption — the point where you decided to show a login surface, which is the last moment you still know where the user was headed. Capturing it later means capturing the login page's own URL, which is how flows end up looping back to login after a successful login.
- 1Capture
At the point of interruption, record the full intended location — path, query and hash — as a path only, never as an absolute URL.
fails by Capturing after the router has already swapped views, so the recorded destination is the login route itself.
- 2Store
Keep it in router location state or session-scoped storage; use a query parameter only if the flow needs to be linkable.
fails by Putting it in a query parameter by reflex, which is what creates the open-redirect surface in the first place.
- 3Render
Show the login surface with an explanation naming what the user is signing in to continue to.
fails by Interpolating the raw destination into the page or an
hrefbefore validating it. - 4Authenticate
The server establishes the session. Possibly via an external identity provider, which must carry the destination through and back.
fails by Losing the destination across the provider round trip, so every federated login lands on the home page.
- 5Validate
Parse the candidate against your own origin and require an exact origin match; check the path resolves to a known route.
fails by A string check —
startsWith('/')accepts//evil.example, and a substring match accepts a lookalike host. - 6Navigate
Replace rather than push, so Back does not return to a completed login form.
fails by Pushing, which strands the user in a two-entry loop between login and destination.
- 7Land
Move focus to the destination's main heading, set the document title, and clear the stored destination.
fails by Leaving focus on a control that no longer exists, and leaving a stale destination that hijacks the next login.
The validate step is the security boundary and the land step is the accessibility boundary. Flows routinely have neither, and the ones that have only the first are still broken for keyboard and screen-reader users.
Parse it, do not pattern-match it
Every bypass in this area is a string check meeting a URL grammar it did not model. //evil.example is an absolute, protocol-relative URL that begins with a slash. https://yourdomain.evil.test contains your domain as a substring. \/\/evil.example is normalised by some parsers into the protocol-relative form. javascript: is a scheme, not a path.
The browser has a correct URL parser and it is available to you. Resolve the candidate against your own origin and compare the resulting origin for exact equality — that single comparison handles the scheme, the host, the port, protocol-relative forms and every encoding trick at once, because it is the same code the browser uses to decide what an origin is (Origins and the Sandbox).
if (next.startsWith('/')) location.assign(next)
// accepts //evil.example (protocol-relative: absolute)
// accepts /\/evil.example (normalised by some parsers)
// and if you add a host substring check, it accepts
// https://yourdomain.evil.testconst url = new URL(next, location.origin)
if (url.origin === location.origin) {
router.replace(url.pathname + url.search + url.hash)
}
// One comparison covers scheme, host, port, protocol-relative
// forms, encoding variants and javascript:/data: schemes,
// because it is the browser's own definition of an origin.The string version is a hand-written approximation of URL grammar, and every bypass in this class is a case the approximation did not model. The parsed version delegates to the implementation that defines what an origin is, so it cannot disagree with the browser about where a navigation would actually go.
1const FALLBACK = '/'2 3export function safeDestination(candidate: string | null): string {4 if (!candidate) return FALLBACK5 let url: URL6 try {7 // Resolve against our own origin. A relative path stays ours;8 // anything absolute reveals its true origin here.9 url = new URL(candidate, location.origin)10 } catch {11 return FALLBACK // not a URL at all12 }13 14 // The whole check, and it covers scheme, host, port and15 // protocol-relative forms in one comparison.16 if (url.origin !== location.origin) return FALLBACK17 18 // javascript: and data: never produce a matching origin, but be explicit:19 if (url.protocol !== location.protocol) return FALLBACK20 21 // Second gate: the path must be a route this application actually has.22 if (!router.hasRoute(url.pathname)) return FALLBACK23 24 // Return a path, never an absolute URL, so callers cannot leave the origin.25 return url.pathname + url.search + url.hash26}27 28// One choke point. Nothing else is allowed to consume `next`.29export function completeLogin(next: string | null) {30 router.replace(safeDestination(next)) // replace, not push31 focusMainHeading() // the browser will not do this for you32}new URL(candidate, location.origin) is doing the security work. Everything after it is defence in depth, and the returned value being a path rather than a URL means a caller cannot reintroduce the bug.
Landing somewhere the user can find
A redirect ends with the user somewhere new, and under client-side routing the browser has done almost nothing to mark the transition: the document is the same, focus is wherever it was, the title may be unchanged, and no announcement has been made. For a sighted mouse user the new content is self-evident. For everyone else, nothing happened.
Two transitions in this flow need handling and both are commonly missed: arriving at the login surface, where the user needs to know why they are there, and arriving at the destination after signing in, where they need to know they got where they were going.
semantics The login view is a main landmark containing an h1 naming the action and the destination; the form is a real form with labelled controls; the destination view has its own h1 and a document title updated on arrival (Semantics Are Behaviour).
| Tab | Reaches the sign-in fields in reading order immediately, because focus was moved into the login view on arrival — not left behind on the page that was interrupted. |
| Enter | Submits the form via its default button, as a native form does (Submission: Method, Encoding and Doing It Once). |
| Escape | If the login is a dialog over the current page rather than a route, dismisses it and returns focus to the trigger (Session Expiry and the Refresh Race). |
| Browser Back | Does not return to a completed login form, because the login step replaced rather than pushed its history entry (History and Navigation). |
- — On arriving at the login view, move focus to its
h1(made focusable withtabindex="-1") or to the first field if there is exactly one obvious next action. - — Do not move focus on every render — only on the transition, or a re-render will steal the caret while the user is typing their password.
- — On arriving at the destination after a successful sign-in, move focus to the destination's main heading. This is the transition most often forgotten, and it leaves keyboard users at the top of a document they must re-explore (Focus Management).
- — If the destination turns out to be forbidden or missing, focus the explanation, not a generic error banner somewhere off-screen.
- — Never trap focus in a login route. A route is not a modal, and the user must be able to reach the rest of the page (Keyboard Operability).
- — On arrival at login: the heading, which should name what is being continued to — "Sign in to continue to Q4 Report" — via the focus move.
- — The reason, if there is one: session expired, permission required, or a first visit. Silence makes the login page look like a bug.
- — A failed attempt: an error associated with the relevant field and announced, not a red outline (Errors People Can Actually Perceive).
- — On arrival at the destination: the new page title and heading, so the user knows the detour is over and where they now are.
usually broken by The pattern invites treating a router redirect as though it were a browser navigation. A browser navigation at least loads a new document and resets focus to it; a router swap changes the view and leaves focus, scroll position and document title exactly as they were — so a screen-reader user hears nothing, a keyboard user is on a control that no longer exists, and the tab still claims to be the page they left.
How to build it
Most important first.
- Store a path, not a URL, and validate it before use: it must parse against your own origin, and the resulting origin must equal
location.origin. Reject everything else and fall back to a safe default (Origins and the Sandbox). - Validate on the way in as well as on the way out. A
nextthat fails validation should never be written into state or rendered into anhref, so it cannot leak into a link the user might click. - Prefer keeping the destination out of the URL entirely — in session-scoped state, or in the router's location state — when the flow allows it. Not being in a query parameter is the strongest defence there is (Persistent Client State).
- Preserve the whole location: path, query and hash. A user returning to a filtered, sorted, scrolled view expects the filters, not the bare route (The URL Is Application State).
- Use
replacerather thanpushfor the login step, so that after arriving at the destination the browser's Back button does not return the user to a login page they have already completed (History and Navigation). - Have exactly one place that performs the post-login navigation, and make it the only code allowed to consume
next. A validator that is bypassable by a second call site is not a validator (What a Component Owes Its Caller). - Move focus and set the document title on arrival — this is a context change that the browser did not perform for you (Focus Management).
- Do the same validation on the server for any server-issued redirect. The client-side check protects the client-side flow and nothing else (The Authorization Code Flow (with PKCE) in Security Engineering).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A redirect is a context change. On arrival at the login view, move focus to the heading or the first field, and update the document title so assistive technology announces where the user now is (Focus Management).
- Say why the login page appeared. "Sign in to continue to Q4 Report" is orientation for everyone and essential for a screen-reader user who did not see the page they were interrupted from (Live Regions and Announcement).
- After a successful sign-in, focus must land in the destination view — on its main heading or its main landmark. Focus left on a submit button that no longer exists puts the user at the top of the document with no announcement (Keyboard Operability).
- Do not auto-redirect on a timer without a way to stop it. A user who needs more time to read the interstitial has to be able to.
- Login errors are form errors: associated with the field, announced, and never colour-only. A failed sign-in during a redirect flow is exactly when a user is most disoriented (Errors People Can Actually Perceive).
- Under client-side routing, none of the above happens by default, which is why every router-driven auth flow needs an explicit focus and title step (History and Navigation).
What can go wrong
- Open redirect:
?next=https://evil.exampleaccepted and followed, turning your login page into a launchpad for credential phishing. javascript:ordata:in a destination that reaches a navigation or anhref— script execution in your origin from a link that came from your own domain (Cross-Site Scripting).- Protocol-relative bypass:
//evil.example/loginpasses astartsWith('/')check and is an absolute URL to the browser. - Redirect loop: the destination requires a permission the user does not have, bounces back to login, which redirects to the destination again (Authorization-Aware UI).
- Back-button trap: login pushed onto history instead of replacing, so Back lands on a completed login form, which redirects forward again, and the user cannot leave.
- The mitigation failing: a validator applied at redirect time but not at render time, so the raw
nextvalue is still interpolated into a link on the page. - Destination lost across an identity-provider round trip, because the parameter was preserved locally but not through the external flow (OAuth 2.x — Delegated Authorization in Security Engineering).
- A login completing in another tab while this tab sits on the login form: this tab should notice it is already authenticated and go to its destination rather than submitting a second login (Auth Across Tabs).
- A destination stored before an expiry and consumed after a long detour through an identity provider — by then the record may be gone or the permission revoked, so the arrival needs its own error handling.
- Two auth failures in flight both storing a
returnTo: last write wins, and the user is returned to whichever request failed most recently rather than to what they were doing (Session Expiry and the Refresh Race).
- The browser enforces nothing about redirect destinations.
location.assign,location.href, aLocationheader and anhrefwill all take the user anywhere they are pointed (The Browser Security Model). - The browser does enforce origin comparison correctly, which is why parsing with
new URLand comparingoriginis reliable where string matching is not (The Same-Origin Policy). - An open redirect is rarely the whole attack and is frequently a step in one: phishing that starts on your domain, or a hop that launders a destination past a filter that trusted your host (Attack Surface in Security Engineering).
- Never put a credential or a one-time code where a redirect can carry it into a referrer or a browser history entry. Codes belong in a single exchange, not in a destination (The Authorization Code Flow (with PKCE) in Security Engineering).
- If your login flow goes through an external identity provider, the provider validates its own redirect URI against a registered list — and your
nextparameter is a separate value your application still has to validate itself (OpenID Connect in Security Engineering). - Do not reflect an unvalidated destination into the page. A raw
nextrendered into an anchor is a stored open redirect one click away (Sanitization and Trusted HTML).
- "It only redirects within my site." Only if you verified that by parsing.
startsWith('/')does not verify it, and neither does a substring match on your domain. - "An open redirect is low severity because it does not run code." It borrows your domain's credibility for a phishing page, and that is usually the whole point of the attack (Follow a Login in Security Engineering).
- "The identity provider validates the redirect URI, so we are covered." It validates the URI registered with it. Your own
nextparameter is a different value and your responsibility (OAuth 2.x — Delegated Authorization in Security Engineering). - "Encoding the destination makes it safe." Encoding is transport, not validation. The decoded value still has to be checked.
- "A redirect is just a navigation, so accessibility does not apply." It is a context change with no announcement and no focus reset, especially under client-side routing where the browser does not even do the little it normally would.
Measuring it, and what changes in the field
- Try the attacks by hand:
?next=https://example.org,?next=//example.org,?next=javascript:alert(1),?next=https://yourdomain.evil.test. All four must land on your safe default (End-to-End Testing). - Network panel: count the hops between clicking Sign In and seeing the destination. More than one redirect is usually a flow that can be flattened (Debugging the Network).
- Field data on how often users land on the fallback destination instead of their intended one — a rising rate means the destination is being dropped somewhere in the flow (Real User Monitoring).
- Tab through the flow start to finish. Where focus lands after each transition is the accessibility test, and it takes under a minute (Accessibility Testing).
- On a slow network the extra hop is felt directly, and a chain of redirects before first render is the worst possible start to a session (The Critical Rendering Path).
- When the session expired mid-task, the destination should include the state the user had — the filters, the tab, the scroll position — not just the route.
- Through an external identity provider, the destination has to survive a round trip out of your application and back, which is where it is most often lost.
- In a long-lived tab, the stored destination can be stale by the time it is used: the record may have been deleted or permission revoked, so the destination needs its own not-found and forbidden handling (Long-Lived Clients and Version Skew).
- Deep links from email and chat clients frequently arrive with tracking parameters attached and sometimes through a link-wrapping redirector, so the "intended destination" you receive is not always the one that was sent.
- Allowlisting by parsed origin is strict and will occasionally reject a legitimate destination — a documentation subdomain, a partner site. Adding those is a deliberate decision each time, which is the point.
- Keeping the destination out of the URL is safer and makes the flow harder to link to, debug and share; a URL parameter is inspectable and reproducible in a way that hidden state is not.
- Preserving full location state means serialising query and hash, which can produce long URLs and needs its own escaping care.
- A single choke point for post-login navigation is indirection, and it is what makes "can this application be used as a redirector" answerable by reading one function.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALURL parsing semantics, protocol-relative resolution and the fact that no browser restricts redirect destinations are common to every engine, which is why the parse-and-compare-origin defence works everywhere and string matching fails everywhere.
- FRAMEWORK-SPECIFICRouters differ in where they let you stash a destination and in what they reset on a route change: some carry opaque location state through history entries, some reset scroll by default and some do not, and none of them move focus for you. Verify what your router does rather than assuming the browser default applies (History and Navigation).
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — the four bypass strings in this lesson are a four-line test case that permanently prevents the most common finding in any login-flow review, and it belongs in the suite rather than in a checklist.