AuthGENERALFRAMEWORK-SPECIFIC

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.

The question

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?

The user intent

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.

The obvious build

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.

Why it breaks

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).

How it breaks in a real browser
  • 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).
  • The scheme is user input too. javascript: in a next parameter is script execution in your origin if it reaches a navigation or an href, and protocol-relative //evil.example is 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).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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') accepts https://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.

From an interrupted intent to a landing, with the checks in place
  1. 1
    Capture

    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.

  2. 2
    Store

    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.

  3. 3
    Render

    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 href before validating it.

  4. 4
    Authenticate

    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.

  5. 5
    Validate

    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.

  6. 6
    Navigate

    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.

  7. 7
    Land

    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).

The check that looks equivalent and is not
String matching
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.test
Parse and compare origin
const 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.

Validating an intended destination
1const FALLBACK = '/'
2
3export function safeDestination(candidate: string | null): string {
4 if (!candidate) return FALLBACK
5 let url: URL
6 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 all
12 }
13
14 // The whole check, and it covers scheme, host, port and
15 // protocol-relative forms in one comparison.
16 if (url.origin !== location.origin) return FALLBACK
17
18 // javascript: and data: never produce a matching origin, but be explicit:
19 if (url.protocol !== location.protocol) return FALLBACK
20
21 // Second gate: the path must be a route this application actually has.
22 if (!router.hasRoute(url.pathname)) return FALLBACK
23
24 // Return a path, never an absolute URL, so callers cannot leave the origin.
25 return url.pathname + url.search + url.hash
26}
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 push
31 focusMainHeading() // the browser will not do this for you
32}

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.

accessibility specRouter-driven redirect to a login view and back to the intended destinationLogin redirect as a context change

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).

TabReaches 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.
EnterSubmits the form via its default button, as a native form does (Submission: Method, Encoding and Doing It Once).
EscapeIf 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 BackDoes not return to a completed login form, because the login step replaced rather than pushed its history entry (History and Navigation).
Focus
  • On arriving at the login view, move focus to its h1 (made focusable with tabindex="-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).
Announces
  • 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 next that fails validation should never be written into state or rendered into an href, 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 replace rather than push for 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

Failure modes
  • Open redirect: ?next=https://evil.example accepted and followed, turning your login page into a launchpad for credential phishing.
  • javascript: or data: in a destination that reaches a navigation or an href — script execution in your origin from a link that came from your own domain (Cross-Site Scripting).
  • Protocol-relative bypass: //evil.example/login passes a startsWith('/') 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 next value 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).
What can arrive out of order
  • 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).
Security
  • The browser enforces nothing about redirect destinations. location.assign, location.href, a Location header and an href will 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 URL and comparing origin is 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 next parameter 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 next rendered into an anchor is a stored open redirect one click away (Sanitization and Trusted HTML).
Misreads
  • "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 next parameter 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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.
What this costs
  • 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.

Domains that do not exist yet
  • 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.
OS & Networkinghttp-basics