---
title: 'Route Guards'
source: 'https://academia.sh/en/courses/frontend-architecture/route-guards'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Route Guards

Access tied to authorization; route guards inherited through the route tree, allow–redirect–hold decisions, handling the unknown-identity state separately, validating the return address, and the limits of client-side enforcement.

The address now carries, in full, which view of which resource is requested. The one
thing it does not carry is whether the user has the right to see that resource. In the
North Slope Measurement Station application, the station settings screen is open only
to authorized users, and measurement entry only to signed-in users.

Hiding the link is not a solution: a path typed by hand into the address bar reaches the
matcher too. The decision has to be made between the match and the view being built.
This lesson covers that decision point, the kinds of decisions, and what a decision
made on the client side does and does not guarantee.

## The Route Guard

A **route guard** is a function that runs before a route's view is built and returns
one of three answers. Its input is the matched route and the current identity; its
output is a decision.

**Allow** does not stand in the way of the view being built. **Redirect** turns the
navigation toward a different address. **Hold** says the decision cannot be made yet;
until the identity arrives, neither the target view nor the redirect is built.

Guards are **inherited** through the route tree. A guard placed on a node applies to
every route beneath it. This was the second use of pathless layout nodes from the
Nested Routes and Layouts lesson: routes sharing the same access condition are grouped
under a single parent node, and the condition is written once.

A route's guards form a chain ordered from root to leaf and run in that order. **The
first one to reject makes the decision**; the remaining guards do not run. The order
is not arbitrary: the session condition comes before the role condition, because knowing
who the user is has to happen before their role can be asked about.

```js
// route-guard.mjs — inherited guard chain, decision types, and validating the return address
const GUARD = {
  session: (u) => u.state === "unknown" ? { decision: "hold" }
    : u.state === "open" ? { decision: "allow" }
      : { decision: "redirect", target: "/session" },
  admin: (u) => u.role === "admin" ? { decision: "allow" }
    : { decision: "redirect", target: "/unauthorized", replaceEntry: true },
  anonymous: (u) => u.state === "open" ? { decision: "redirect", target: "/station", replaceEntry: true }
    : { decision: "allow" },
};

// Guards are inherited in the route tree: the one on a node applies to its whole subtree.
const TREE = [
  { path: "/", guards: [] },
  { path: "/session", guards: ["anonymous"] },
  { path: "/station", guards: ["session"] },
  { path: "/station/:id/measurements", guards: ["session"] },
  { path: "/station/:id/settings", guards: ["session", "admin"] },
];

const BASE = "https://example.test";

function evaluate(route, user, requestedAddress) {
  for (const name of route.guards) {
    const result = GUARD[name](user);
    if (result.decision === "allow") continue;
    if (result.decision === "hold") return { decision: "hold", guard: name };
    const target = result.target === "/session"
      ? `/session?return=${encodeURIComponent(requestedAddress)}`
      : result.target;
    return { decision: "redirect", guard: name, target, replaceEntry: result.replaceEntry ?? true };
  }
  return { decision: "allow" };
}

const users = [
  { name: "loading", state: "unknown", role: null },
  { name: "guest", state: "closed", role: null },
  { name: "observer", state: "open", role: "observer" },
  { name: "admin", state: "open", role: "admin" },
];

console.log("-- decision table --");
for (const u of users) {
  for (const route of TREE) {
    const requested = route.path.replace(":id", "north-slope");
    const r = evaluate(route, u, requested);
    console.log([
      u.name.padEnd(11), requested.padEnd(30), r.decision.padEnd(10),
      (r.target ?? "-").padEnd(48), r.guard ? `(${r.guard})` : "",
    ].join(" ").trimEnd());
  }
}

// The return address comes from the user; it is validated to prevent an open redirect.
function resolveReturn(raw) {
  if (raw === null) return "/station";
  let parsed;
  try { parsed = new URL(raw, BASE); } catch { return "/station"; }
  if (parsed.origin !== BASE) return "/station";
  if (!raw.startsWith("/") || raw.startsWith("//")) return "/station";
  return parsed.pathname + parsed.search;
}

console.log("-- validating the return address --");
for (const raw of [
  "/station/north-slope/settings?measurement=humidity",
  "https://evil.example/collect",
  "//evil.example/collect",
  "station/north-slope",
  "/station/../../etc",
  null,
]) console.log(JSON.stringify(raw).padEnd(40), "->", resolveReturn(raw));
```

```
-- decision table --
loading     /                              allow      -
loading     /session                       allow      -
loading     /station                       hold       -                                                (session)
loading     /station/north-slope/measurements hold       -                                                (session)
loading     /station/north-slope/settings  hold       -                                                (session)
guest       /                              allow      -
guest       /session                       allow      -
guest       /station                       redirect   /session?return=%2Fstation                       (session)
guest       /station/north-slope/measurements redirect   /session?return=%2Fstation%2Fnorth-slope%2Fmeasurements (session)
guest       /station/north-slope/settings  redirect   /session?return=%2Fstation%2Fnorth-slope%2Fsettings (session)
observer    /                              allow      -
observer    /session                       redirect   /station                                         (anonymous)
observer    /station                       allow      -
observer    /station/north-slope/measurements allow      -
observer    /station/north-slope/settings  redirect   /unauthorized                                    (admin)
admin       /                              allow      -
admin       /session                       redirect   /station                                         (anonymous)
admin       /station                       allow      -
admin       /station/north-slope/measurements allow      -
admin       /station/north-slope/settings  allow      -
-- validating the return address --
"/station/north-slope/settings?measurement=humidity" -> /station/north-slope/settings?measurement=humidity
"https://evil.example/collect"           -> /station
"//evil.example/collect"                 -> /station
"station/north-slope"                    -> /station
"/station/../../etc"                     -> /etc
null                                     -> /station
```

Every row in the table is a pair of decisions: who, and where to. The observer sees the
measurement history but cannot get into settings; the admin gets into both. The root
path and the sign-in screen carry no guard or an inverse guard.

## The Inverse Guard

The `anonymous` guard does the opposite of the others: it moves a user whose session is
**open** away from the sign-in screen. In an application that skips this, a signed-in
user returns to the sign-in screen with the back button and does not know what to do
there; if they try to sign in again, the identity flow runs twice.

The same inverse logic applies to registration, password reset, and welcome flows.

## Unknown Identity

The third kind of decision shows up in the first five rows of the output. When the
application first loads, whether the session is open is not known yet: this information
either arrives from the server through a request, or gets read from a local record.
Both take time.

The most common mistake made during this interval is **treating unknown as
unauthorized**. The outcome is familiar: the user reloads a protected page, sees the
sign-in screen for a moment, then returns to the actual page once the identity arrives.
The screen changing twice like this is not just uncomfortable; the address may have
changed twice too, leaving an unnecessary history entry.

The correct behavior is to defer the decision. Routing that receives a hold answer
builds neither the target view nor the sign-in screen until identity arrives; it leaves
a loading indicator in place instead. Once identity arrives, the guard runs again and
the real decision is made.

Holding must have a limit. If the identity request fails, the decision cannot be
deferred forever; failure is tied to the same outcome as being unauthorized, and the
user is told why.

## The Return Address

When a guest user goes to a protected address, they are sent to the sign-in screen.
Landing on the welcome screen after signing in would mean forgetting where they wanted
to go. So the requested address is written into the redirect's query and read back
from there once the session opens.

This parameter is under the user's control. An attacker can put their own address in
the `return` value and share the link; the user signs in on a site they recognize and
is immediately sent somewhere else. This flaw is called an **open redirect**, and its
prevention fits in one sentence: the return address is validated as belonging to the
application's own origin before it is used.

The final part of the output shows what the validation catches. A fully qualified
external address is rejected. A protocol-relative address starting with a slash — the
form that sends the browser to an external host on the same protocol — is rejected too;
this is the case a check that only tests the first character misses. A relative address
is also rejected, because which base it resolves against is ambiguous. What is left is
only in-application paths, and these get normalized as they are resolved.

The same test applies everywhere an address is taken from the user: target addresses
carried in a query, notification links, return buttons on an error page.

## How the Redirect Is Written to History

The redirect a guard produces is not a navigation the user **asked for**. So it does not
push a new history entry; it replaces the current one. Otherwise, when the user presses
back from the sign-in screen, they would return to the protected address, the guard would
run again, and it would send them back to the sign-in screen — the back button would
stop working.

This is why the decisions in the example carry a `replaceEntry` flag. The distinction
established in the History and Location lesson applies directly here: states the user
would want to return to are pushed, intermediate addresses are replaced.

## The Limits of Client-Side Enforcement

Everything this lesson has set up is an **interface decision**, not a security
boundary. Three reasons show this.

The guard's code runs on the user's machine and can be read; role names, conditions,
and hidden routes' paths sit inside the bundle. The user can choose not to run the
guard: requesting the address directly, crafting the request by hand, or modifying the
response is within their control. The data a protected view uses is also fetched
through a request; hiding the view does not hide the data.

This is why **every protected operation is re-authorized on the server**. Client-side
enforcement keeps an unauthorized user from running into a blank screen or a stack of
errors; it does not protect the data.

The same distinction applies to hidden information. Hiding a menu item based on role
information is acceptable; but data only an admin should see cannot be sent to the
client tucked inside a hidden component. Everything sent can be seen.

## Summary

- A route guard runs between the match and the view being built, and gives one of
  three decisions — allow, redirect, or hold; guards are inherited through the route
  tree, run in order as a chain, and the first one to reject makes the decision.
- The state where identity is not known yet is separated from being unauthorized; the
  decision is deferred, but the deferral ends once it fails.
- The inverse guard moves a user with an open session away from the sign-in and
  registration screens.
- The return address is user input; using it without validating that it belongs to the
  application's own origin produces an open redirect flaw.
- The redirect a guard produces does not push a history entry; it replaces the current
  one.
- Client-side enforcement is an interface decision; every protected operation and every
  piece of protected data is re-authorized on the server.

## Next Step

Routing now knows which screen is open to whom. What it does not know is when that
screen's code gets downloaded. Right now, every screen in the application — the station
list, the settings form, the measurement chart, the sign-in flow — comes down together
in a single bundle. Even a user who came only to sign in downloads the admin settings
screen's code. The route tree already carries the information needed to make this
split: which component is used on which path is written into the definition. The next
lesson covers splitting the bundle along route boundaries, avoiding duplication of
shared parts, and the waiting and error states that splitting introduces.
