Lesson 04 / 22
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.
Contents
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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.