Navigation guards
Assignment
Martina Hostettler has written down two wishes.
As a volunteer at the counter, I want to sign in, so that only the team sees the area for loans and returns.
As a member, I want a warning before I leave a half-filled form, so that my input doesn't get lost.
The acceptance criteria are:
- Anyone who isn't signed in with a team account and opens a page of the team ends up on the login page.
- After the login, the app leads to the page that the person wanted to open.
- Anyone who wants to leave a page with a half-filled form gets asked and can stay.
Learning goals
After this chapter you can
- describe the flow of a navigation with its guards,
- write a global guard with
beforeEachand fields inmeta, - lead back to the original target after the login and accept only targets inside the app,
- write a guard for a single route with
beforeEnter, - ask before someone leaves a page with
onBeforeRouteLeave, - explain why a guard in the browser protects no data.
The navigation flow
Section titled “The navigation flow”Between the click on a link and the new page, the router asks a series of functions whether the navigation may go on. These functions are called navigation guards. The documentation describes the exact order, and the figure shows the steps that this course needs.
A guard gets the target to and the previous route from. If it returns nothing, the navigation goes on. false cancels the navigation, and a route redirects to a new navigation. Older examples use a third argument, next. The documentation still supports it but advises against it, because it's a common source of bugs.
Global guards with beforeEach
Section titled “Global guards with beforeEach”router.beforeEach runs before every navigation. Instead of a list of paths, the guard checks a field in meta that the route itself carries. According to the documentation, to.meta contains the fields of all matching routes, from the parent route down to the child route. A flag on /team therefore also applies to /team/returns.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() { member.value = null}</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <RouterLink to="/login">Sign in</RouterLink> <button v-if="member" type="button" @click="signOut">Sign out</button> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-insexport const member = ref(null)CatalogView.vue
<template> <h1>Catalog</h1> <p>60 things to borrow.</p></template>LoginView.vue
<script setup>import { useRoute, useRouter } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()const router = useRouter()
// The target comes from the address, so it is input from outside. Only paths inside the app count.function target() { const redirect = route.query.redirect return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'}
function signIn() { member.value = { name: 'Martina Hostettler', team: true } router.replace(target())}</script>
<template> <h1>Sign in</h1> <p v-if="route.query.redirect">Afterwards you go on to {{ route.query.redirect }}</p> <button type="button" @click="signIn">Sign in as Martina Hostettler</button></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', name: 'login', component: LoginView }, { path: '/team', component: TeamView, meta: { requiresTeam: true } }, ],})
// Runs before every navigation. Nothing lets it pass, a route location redirects.router.beforeEach((to) => { if (to.meta.requiresTeam && !member.value?.team) { return { name: 'login', query: { redirect: to.fullPath } } }})
export default routerTeamView.vue
<template> <h1>Team at the counter</h1> <p>For the team only.</p></template>Without a login, a click on "Team" leads to /login?redirect=/team. The guard passes the target along as redirect in the query, so the login page knows where to go afterwards.
The login as a stand-in
Section titled “The login as a stand-in”A server has to check a login, and that comes in part 7. Until then, the running case app has a stand-in in src/auth.js. It knows two members, Martina Hostettler with the number M-117 and a team account, and member M-204 without one. Signing in only takes the member number, and the code contains no passwords. The module keeps the state itself, and the login is gone after a reload.
A guard in the browser protects no data. It makes sure that members don't end up on pages that aren't meant for them by accident. Anyone who changes the code in the browser gets past it. According to the OWASP Top 10:2025, access control only works in code on a server that an attacker can't modify. The server therefore has to protect the data of the team.
Back to the target
Section titled “Back to the target”After the login, the page reads the target from route.query.redirect and goes there with router.replace. replace fits because the login doesn't need a step of its own in the history. The value comes from the address, though, and anyone can build an address and send it around. The OWASP cheat sheet on redirects recommends accepting only addresses of your own site for such return targets. The login therefore checks that the target starts with a single /, and otherwise it goes to the catalog.
Guards for a single route
Section titled “Guards for a single route”A guard that belongs to only one route goes into that route as beforeEnter. According to the documentation, it only runs when someone enters the route, not when params, query or hash change.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() { member.value = null}</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <RouterLink to="/login">Sign in</RouterLink> <button v-if="member" type="button" @click="signOut">Sign out</button> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-insexport const member = ref(null)CatalogView.vue
<template> <h1>Catalog</h1> <p>60 things to borrow.</p></template>LoginView.vue
<script setup>import { useRouter } from 'vue-router'
import { member } from './auth.js'
const router = useRouter()
function signIn() { member.value = { name: 'Martina Hostettler', team: true } router.push('/team')}</script>
<template> <h1>Sign in</h1> <button type="button" @click="signIn">Sign in as Martina Hostettler</button></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', component: LoginView, // Runs only when someone enters this route, whoever is signed in goes straight on beforeEnter: () => (member.value ? '/team' : true), }, { path: '/team', component: TeamView }, ],})TeamView.vue
<template> <h1>Team at the counter</h1> <p>For the team only.</p></template>Once you're signed in, "Sign in" takes you straight to the team area. For a guard that concerns only one route, beforeEnter is easier to follow than a condition in beforeEach. When a guard checks many routes, beforeEach with meta fits better.
Warning before leaving
Section titled “Warning before leaving”onBeforeRouteLeave runs before someone leaves the page that the component is on. According to the documentation, this works in any component that a RouterView renders, even deep inside the page, which in the running case app is the reservation form.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/items/6">Reserve the projector</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<template> <h1>Catalog</h1> <p>60 things to borrow.</p></template>ItemView.vue
<script setup>import { computed, ref } from 'vue'import { onBeforeRouteLeave } from 'vue-router'
const note = ref('')const dirty = computed(() => note.value.trim() !== '')
// Asks before someone leaves the page with a half-filled form, false keeps the pageonBeforeRouteLeave(() => { if (dirty.value && !window.confirm('Your input will be lost. Leave the page anyway?')) { return false }})</script>
<template> <h1>Projector</h1> <label for="note">Note on the reservation</label> <textarea id="note" v-model="note"></textarea></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/items/:id', component: ItemView }, ],})If the field contains something, window.confirm asks whether to really leave the page, and on "Cancel" the guard stops the navigation with false. Reloading and closing the tab aren't changes of route. For those, the browser has the beforeunload event, which this course doesn't use any further.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the first example, click "Team" without signing in. Which address does the paragraph show, and what does the login page say?
- Let the guard return
falseinstead of the route. What happens now when you click "Team"? - In the example with
beforeEnter, sign in, go back to the catalog and click "Sign in". - In the example with the form, write a note and click "Catalog". Answer once with "Cancel" and once with "OK".
Exercises
Section titled “Exercises”The exercises build the login and the warning from the assignment. You'll find the project in the course repo in the folder exercises/06-routing/guards/en. The stand-in for the login is already in src/auth.js. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Protect the team area with a global guard in src/router/index.js. Anyone who isn't signed in with a team account and wants to open a route with meta.requiresTeam ends up on the route login, and the query keeps the target as redirect.
Solution
// Runs before every navigation. Nothing lets it pass, a route location redirects.router.beforeEach((to) => { if (to.meta.requiresTeam && !currentMember.value?.team) { // The login leads back to the target afterwards return { name: 'login', query: { redirect: to.fullPath } } }})currentMember comes from src/auth.js. The question mark in currentMember.value?.team covers the case where nobody is signed in and the value is null.
Exercise Going further
After the login, LoginView leads to the address from redirect, and to the catalog when there is no redirect. Only paths inside the app count, which means values that start with a single /.
Solution
// The target comes from the address, so it is input from outside. Only paths inside the app count.function target() { const redirect = route.query.redirect return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'}
function submit() { if (!signIn(number.value)) { error.value = 'We do not know this member number.' return } router.replace(target())}typeof redirect === 'string' rules out a missing value, and also a query with two values, which arrives as an array. startsWith('//') catches an address without a protocol that points to another host.
Exercise Challenge
Warn before someone leaves the page of a thing while the form contains something. Ask with window.confirm, and stay on the page if the answer is "Cancel".
Solution
// Something typed into the form would be lost when leaving the pageconst dirty = computed(() => Object.values(request).some((value) => value.trim() !== ''))
// Runs before the page is left, false keeps itonBeforeRouteLeave(() => { if (dirty.value && !window.confirm('Your input will be lost. Leave the page anyway?')) { return false }})dirty checks all fields at once. After sending, Object.assign clears the form, and the warning doesn't appear. The tests replace window.confirm with vi.stubGlobal and give the answer themselves, while in the browser the dialog appears.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
The console reports Detected a possibly infinite redirection in a navigation guard. | The guard redirects the login page itself as well. Only redirect for routes with meta.requiresTeam, and return nothing otherwise. |
next gets called twice or not at all, and the navigation hangs. | Return a value instead of calling next, either false, a route or nothing. |
After the login you end up at / instead of the page you wanted. | Pass the target along as redirect in the query and read it after the login. |
| The warning doesn't appear on a reload or when the tab closes. | That's not a change of route. The browser has the beforeunload event for it. |
| An AI assistant protects the data of the team with a guard alone. | A guard steers members, it doesn't protect anything. The server checks access, see part 7. |
Self-check
Section titled “Self-check”Self-check
- What can a guard return, and what does each value do?
- Why does the guard check a field in
metainstead of a list of paths? - Why does the login check the target from the address?
- Why doesn't a guard in the browser protect any data?
Answers
- Nothing or
truelets the navigation go on,falsecancels it, and a route redirects to a new navigation. - The flag sits on the route itself and also applies to its child routes, and a new page of the team needs no change in the guard.
- Anyone can build an address with a target and send it around. Only paths of the app are safe as targets.
- The code runs in the browser of the person who wants to get around it. Only code on the server can check access reliably.
Assignment
Only the team gets into its area, and no form gets lost unnoticed anymore. At the end, the volunteer looks at what comes over the network on the first load. "Every member loads the code for the team area, even though they never get to see it. And a wrong address shows an empty page." The next chapter solves both.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.