skip to content

In React Router v7, how does a login page send the user back to location.state.from or a redirectTo param, and what must it validate?

level: middleimportance: should knowfreq 55%

answer

  1. history entry versus URL
  2. navigate with replace
  3. the action can return redirect()
  4. one leading slash, never two

basics

~20 s

After sign-in, a component calls navigate(from, { replace: true }); a data-router login action returns redirect(target). A redirectTo read from the URL is attacker-controllable, so accept only a same-app path starting with a single '/' and fall back to '/'.

solid answer

~40 s

There are two carriers. `location.state.from`, set by a `<Navigate state>` guard, lives on the history entry: it cannot be forged through a link, but it is lost in a new tab. A `?redirectTo=` param, set by a loader guard, survives new tabs and bookmarks, but anyone can craft it. To return the user, a component calls `navigate(target, { replace: true })` so `/login` drops out of Back; in a data router the login `action` returns `redirect(target)`. Validate before either: React Router's own docs say `redirect()` accepts absolute URLs and can navigate to external domains — in 7.18.4 a cross-origin `Location` becomes a full-document navigation. So accept only values that start with `/` but not `//` or `/\`, never `/login` itself, and fall back to `/`.

code

ts · 18 lines
ts
import { redirect, type ActionFunctionArgs } from "react-router";
import { signIn } from "./auth";

export function safeRedirect(target: unknown, fallback = "/"): string {
  if (typeof target !== "string") return fallback;
  if (!target.startsWith("/")) return fallback;       // absolute or relative URL
  if (target.startsWith("//")) return fallback;       // protocol-relative
  if (target.startsWith("/\\")) return fallback;      // normalised to // by browsers
  if (target === "/login" || target.startsWith("/login?")) return fallback;
  return target;
}

export async function loginAction({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  await signIn(form.get("email"), form.get("password"));
  const redirectTo = new URL(request.url).searchParams.get("redirectTo");
  return redirect(safeRedirect(redirectTo));
}

go deeper

for a junior

Know both carriers — location.state.from and a redirectTo param — and navigate back with replace after sign-in.

for a middle

Explain which carrier survives a new tab, how a data-router login action returns redirect(target), and why the query-string carrier must be validated.

for a senior

Know the router's actual behaviour: loader and action redirects to other origins become document navigations, navigate() throws on them, and one shared helper enforces a single leading slash.

for a principal

Make the return-to rule a platform helper used by every sign-in path, including SSO callbacks, and review new flows for redirect targets that bypass it.

## Two ways the destination reaches the login page The scenario: a signed-out user opens a deep link to `/admin/users?tab=roles`, a guard sends them to `/login`, and after signing in they should land on the exact page they asked for. The guard can hand the destination over in two ways. | | `location.state.from` | `?redirectTo=` search param | |---|---|---| | Set by | `<Navigate to="/login" state={{ from: location }} />` | a loader throwing `redirect("/login?redirectTo=…")` | | Stored in | the history entry, not the URL | the URL itself | | New tab, bookmark, shared link | lost | kept | | Who can set it | only your code | anyone who can send the user a link | | Needs validation | still worth a type check | **mandatory** | ## Sending the user back **Component flow** (declarative mode, or a login form without an action): 1. Read `const from = location.state?.from` from `useLocation()`. 2. After the sign-in call succeeds, call `navigate(from?.pathname ?? "/", { replace: true })`, appending `from.search` if you need the query string. 3. `replace: true` swaps the `/login` entry out, so Back goes to the page before the login flow instead of back into the form. **Data-router flow** (login page with an `action`): 1. The `<Form>` posts to the login route's `action`. 2. The action reads `redirectTo` from `new URL(request.url).searchParams` (or from a hidden form field). 3. It signs the user in and returns `redirect(safeTarget)`. The router follows it after the action completes. ## Why validation is required in this router's terms `redirectTo` arrives from the URL, so an attacker can send a victim a link such as `/login?redirectTo=https://phish.example/`. React Router does not protect you here: - `redirect()`'s own documentation says it *"accepts absolute URLs and can navigate to external domains, so the application should validate any user-supplied inputs to redirects"*. - In the pinned 7.18.4 runtime, when a loader or action redirect points at another origin, the router performs a **full-document navigation** with `window.location` instead of a client-side one — the user leaves your app. - `navigate()` and `<Navigate>` in the same version **throw** `External navigation is not allowed` for cross-origin targets. That blocks the worst case in the component flow, but it is a crash, not validation, and it does nothing for same-origin targets you did not intend, such as `/logout`. ## A safe check Accept a candidate only when all of these hold, otherwise use a default like `/`: - it is a string that **starts with `/`**, - it does **not** start with `//` (a protocol-relative URL, which browsers treat as another host), - it does **not** start with `/\` (browsers normalise a backslash to a slash in URLs, turning it into `//`), - it is **not the login route itself**, which would loop. Keep the helper in one module and use it in both flows, so the rules cannot drift. ## Choosing a carrier Use `location.state` when the guard is a wrapper component and you do not need deep links to survive a new tab — it needs no validation beyond a shape check, because only your code can put it there. Use a `redirectTo` search param when the guard is a loader, when sign-in can happen in another tab or through an external identity provider, or when the login URL may be bookmarked. Many apps use both: prefer `state.from` when present and fall back to a validated `redirectTo`. ## Putting the reserved scenario together 1. The `/admin` subtree is guarded; a loader throws `redirect("/login?" + new URLSearchParams({ redirectTo: "/admin/users?tab=roles" }))`. 2. The login action validates `redirectTo`, signs in, and returns `redirect("/admin/users?tab=roles")`. 3. The guard now passes; the admin loaders run; the user lands on the tab they asked for, and Back does not return to the form in the component flow. Open-redirect vulnerabilities as a class are an application-security topic; the React Router part is knowing that `redirect()` will happily leave your origin, and that the check belongs in your code.

  • If navigate() already throws for cross-origin targets in 7.18.4, why validate in the component flow?
    Because throwing is not handling: the user sees an error instead of landing somewhere sensible, and the check does nothing for same-origin targets you did not intend, such as `/logout` or `/login`. A helper that falls back to `/` gives predictable behaviour on every path.
  • Why pass replace: true when navigating back after sign-in?
    Without it, history holds `/login` between the previous page and the destination, so Back from the destination reopens the login form for an already signed-in user. `replace: true` swaps the login entry for the destination.

saying these in an interview costs you the question

  • React Router's redirect() refuses external URLs, so redirectTo needs no check
  • Checking that redirectTo starts with '/' is enough on its own
  • location.state.from survives opening the login link in a new tab
  • After sign-in, navigate without replace so Back returns to the login form
  • A redirectTo pointing at /login itself is harmless