Home › Frontend › Vue Router Guard Redirects to Itself Forever: Loop Fix
Intermediate 5 min · September 23, 2026
Vue Router Navigation Guard Infinite Redirect

Vue Router Guard Redirects to Itself Forever: Loop Fix

A guard that redirects to a route it also guards loops forever.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 9 min
  • ✓Vue Router basics: routes, navigation, and global guards
  • ✓How auth tokens gate access to protected pages
  • ✓Differences between Router 3 next() and Router 4 return style
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • A global guard redirecting unauthenticated users to /login loops when /login itself requires auth
  • Allowlist public routes with meta: { requiresAuth: false } and skip the redirect for them
  • In Router 4 return the redirect path; never call next() twice or next() plus a return
  • Preserve the target with redirect query so login can send users back after success
  • Confirm with a logged-out hard load of /login: one redirect, then the page settles
✦ Definition~90s read
What is Vue Router Navigation Guard Infinite Redirect?

A router redirect loop is a navigation guard that redirects to a route satisfying its own redirect condition. The standard shape: a global beforeEach sends unauthenticated users to /login, but /login isn't exempt, so the redirect re-triggers the guard, fails the same check, and redirects again — an infinite ping-pong of navigations.

★
Imagine a bouncer who sends everyone without a wristband to the wristband desk — but the wristband desk is inside the club, past the same bouncer.

Variants include role guards bouncing users between two guarded pages, stacked global plus per-route guards disagreeing about edge cases, and next() called twice double-driving one navigation.

Vue Router 4 guards decide by return value: true proceeds, false cancels, and a path or location object redirects. Vue Router 3 uses the next() callback: next(), next(false), or next('/login') — exactly once per guard run. Redirects are full new navigations, which is why the guard re-fires on its own target.

The router eventually aborts with a navigation failure, but users experience flicker and dead ends long before that.

The loop-proof structure has three parts. Meta fields declare policy per route (requiresAuth, roles) with public-by-default polarity so redirect targets terminate the cycle. A single global guard enforces policy, preserving the deep-link target in the redirect query for post-login return.

Tests exercise every path logged-out in fresh profiles — direct loads of login, signup, and protected URLs — because developer tokens structurally skip the redirect branch where loops live.

Plain-English First

Imagine a bouncer who sends everyone without a wristband to the wristband desk — but the wristband desk is inside the club, past the same bouncer. Guests bounce between the door and the desk forever. That's a guard redirect loop: your auth guard sends strangers to the login page, but the login page is also guarded, so the guard fires again and sends them to login again. The fix is a guest list: mark login as public so the bouncer waves those guests straight through.

You add an auth guard, reload the page, and the browser spins: the URL flickers, the console fills with redirect warnings, and eventually the router gives up with too many redirects or a stack of navigation failures. Login is unreachable precisely when you need it most — logged out. Every Vue app with auth hits some version of this, usually the night before launch.

The loop structure is always the same. A global beforeEach checks authentication and redirects strangers to the login route. But the login route also passes through that global guard, fails the same check, and redirects to itself. Each redirect is a new navigation, which re-runs the guard, which redirects again. A close cousin: next('/login') combined with logic that also returns a value, double-driving the navigation, or two guards (global plus per-route) that bounce users between dashboard and login.

This article gives you the loop-proof pattern: meta-field auth flags with a public-route allowlist, single-decision guard returns, preserved post-login targets, and a reproduction checklist that proves the cycle is dead. It covers both Vue Router 4 return-style guards and the older next() API many codebases still run.

Anatomy of the Loop: Guard Redirects Into Its Own Condition

Every redirect loop has three parts: a guard, a condition, and a target that satisfies the condition. The global guard checks authentication; the condition is missing token; the target is /login. Because redirects are new navigations, /login re-enters the same global guard, fails the same check, and redirects to /login again. The router dutifully executes this forever — or until its redirect detection aborts with an error after too many hops.

The same shape appears with roles: a guard bouncing non-admins from /admin to /dashboard while another guard bounces unauthenticated users from /dashboard to /login, with a misclassified user tripping both alternately. And with stacked guards: a global beforeEach plus a per-route beforeEnter each redirecting under slightly different logic, disagreeing about where the user belongs. All variants share the signature of URL flicker plus navigation-failure warnings.

Seeing the loop as condition-meets-target makes the fix obvious: the redirect target must never satisfy the redirect condition. Public routes fail no auth check, so redirecting to them terminates. That single invariant — targets are exempt — is what every pattern below enforces, whether through meta fields, allowlists, or consolidated guards.

router.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/login', component: () => import('./Login.vue') },
  { path: '/dashboard', component: () => import('./Dash.vue'), meta: { requiresAuth: true } }
]

const router = createRouter({ history: createWebHistory(), routes })

// BAD: redirects EVERYTHING unauthenticated — including /login itself
router.beforeEach((to) => {
  if (!localStorage.getItem('token')) return '/login'
})
Try it live
📊 Production Insight
URL flicker between two app routes is a purely client-side signature. When you see it, stop restarting backend services and read the guards.
🎯 Key Takeaway
A loop is a guard whose target satisfies its own condition. Exempt redirect targets and the cycle can't form.

The Meta-Field Pattern: requiresAuth With Public-by-Default Routes

Mark protected routes with meta: { requiresAuth: true } and leave login, signup, and marketing pages unmarked. The global guard then checks to.meta.requiresAuth — or to.matched.some(r => r.meta.requiresAuth) for nested routes — and redirects only those. Since /login carries no flag, redirecting to it terminates the cycle by construction. Public-by-default is the safer polarity: a forgotten flag exposes a page, while the inverse (auth-by-default) loops the login page the moment anyone touches the guard.

Extend the pattern with roles without adding guards: meta: { requiresAuth: true, roles: ['admin'] } lets one global guard check both authentication and authorization in sequence. Redirect strangers to login with the target preserved; redirect authenticated-but-unauthorized users to a not-authorized page (also public). Each target is exempt from the condition that sent the user there, preserving the termination invariant at every branch.

Keep meta serializable and simple — booleans, strings, arrays. Complex objects in meta invite logic that belongs in the store or a dedicated auth module the guard consults. The route table should declare policy; the guard should enforce it. When policy lives in meta, a glance at the routes file audits your entire auth surface.

router-guarded.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/login', component: () => import('./Login.vue') },
  { path: '/signup', component: () => import('./Signup.vue') },
  {
    path: '/dashboard',
    component: () => import('./Dash.vue'),
    meta: { requiresAuth: true, roles: ['member', 'admin'] }
  }
]

const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach((to) => {
  if (to.matched.some((r) => r.meta.requiresAuth)) {
    const token = localStorage.getItem('token')
    if (!token) return { path: '/login', query: { redirect: to.fullPath } }
  }
})
Try it live
📊 Production Insight
Public-by-default meta means new pages are reachable until classified — the failure mode is exposure, which code review catches, instead of lockout loops, which users catch.
🎯 Key Takeaway
Flag protected routes with meta, keep public routes unmarked, and gate the redirect on the flag.

next() Misuse in Router 3 Codebases: Call It Once, Then Stop

In Vue Router 3, guards receive next() and must call it exactly once: next() to proceed, next(false) to cancel, next('/login') to redirect. Loops breed in the gaps — a next('/login') inside an if followed by an unconditional next() at the function's end calls next twice, double-driving the navigation into undefined behavior. Async guards are the classic site: the awaited check calls next, then execution falls through to a second call.

The discipline is early returns: every branch ends with return next(...) so no path reaches a second call. For async guards, return the promise chain or await then return, ensuring the function can't continue past the decision. Lint rules against fallthrough after next() catch most violations mechanically.

Vue Router 4 keeps next() working but prefers returns: return true (proceed), false (cancel), or a path/location (redirect). Mixing styles — calling next() AND returning a value — double-drives the navigation and recreates the old bugs in new code. When migrating, convert guards wholesale to return style; a file with both idioms is a loop waiting for its trigger. Either API is loop-safe once each navigation gets exactly one decision, so pick one and commit.

guard-next.jsJAVASCRIPT
1
2
3
4
5
6
7
8
// Router 3 style: exactly one next() per path, always returned
function authGuard(to, from, next) {
  const token = localStorage.getItem('token')
  if (to.meta.requiresAuth && !token) {
    return next({ path: '/login', query: { redirect: to.fullPath } })
  }
  return next() // single fallthrough decision
}
Try it live
📊 Production Insight
Mixed next()/return guards are the top loop source in migrating codebases. Convert each guard file completely or not at all — half-migrated guards double-drive.
🎯 Key Takeaway
One navigation, one decision: early-return every next() branch, and never mix next() with return values.

Preserving the Target So Login Returns Users Home

A guard that dumps everyone on the homepage after login technically works and practically annoys. Users who clicked a deep link — an invoice, a shared report — must navigate back manually, and some conclude the link was broken. Preserving the target is a small addition with outsized trust value: attach query: { redirect: to.fullPath } to the login redirect, then after successful auth push the saved path.

Handle the edges deliberately. Validate the redirect target before pushing: accept only in-app paths (starting with / but not //) to block open-redirect attacks that smuggle external URLs through your login flow. Default to a sensible home when no target exists (direct login visits). Clear the query after consuming it so refreshes don't replay stale destinations.

Test the full journey, not just the guard. Automated: logged-out deep URL, expect login with redirect query; perform login; expect arrival at the deep URL. Manual edge cases include expired sessions mid-form (target should survive re-auth) and role changes (a target the user can no longer access should land on not-authorized, not loop). The target is part of the auth contract — treat it with the same rigor as the token check.

LoginView.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

async function login() {
  await fakeAuth() // sets the token
  const target = route.query.redirect
  const safe = typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')
  await router.push(safe ? target : '/dashboard')
}
Try it live
📊 Production Insight
Open-redirect via unvalidated redirect queries turns your login page into a phishing asset. Validate in-app-only targets before pushing.
🎯 Key Takeaway
Save to.fullPath in the redirect query, validate it on return, and test the whole deep-link journey.

Consolidating Stacked Guards Into One Decision-Maker

Loops love guard stacks: a global guard plus per-route beforeEnter plus in-component beforeRouteEnter, each redirecting under slightly different logic. Individually each looks right; together they disagree about where edge-case users belong, bouncing them between routes. Debugging is miserable because no single file contains the cycle — it emerges from the interaction.

Consolidate ruthlessly. One global guard owns authentication and authorization, reading meta fields (requiresAuth, roles) and consulting the auth store. Per-route guards remain only for route-specific concerns that aren't auth — data preconditions, feature flags, dirty-form confirmations. In-component guards handle component concerns like unsaved changes. Auth redirects live in exactly one place.

When consolidation isn't immediately possible, map the interaction explicitly: list every guard touching the bouncing routes, with conditions and targets, and find the pair whose targets satisfy each other's conditions. That map usually reveals a redundant guard to delete — the incident's dashboard beforeEnter existed because the global guard predated meta fields. Delete the redundancy, keep the single owner, and the emergent cycle collapses.

⚠ Two Guards Redirecting Is One Loop Waiting
If a global guard and a per-route guard can both redirect the same user, they will eventually disagree. Keep auth decisions in one global guard; reserve other guards for non-auth concerns.
📊 Production Insight
Emergent guard interactions never appear in single-file review. Any PR adding a redirecting guard must include the full guard-interaction map for affected routes.
🎯 Key Takeaway
One global guard owns auth; per-route guards handle non-auth concerns only.

Proving the Cycle Is Dead: Logged-Out Navigation Tests

Guards must be tested from the victim's perspective: logged out, fresh profile, no token. Write navigation tests that hard-load /login, /signup, and a deep protected URL without credentials, asserting each settles within two navigations and lands on the right page. Then test the authenticated matrix: valid token reaches protected routes, wrong role reaches not-authorized, expired token mid-session redirects once with the target preserved.

Reproduce manually with an incognito window before declaring victory — developers' long-lived tokens hide loops by skipping the redirect branch. Throttle the network while at it: slow token validation can interleave with redirects in async guards, creating timing loops that fast localhost never shows. Watch the address bar during each test; any flicker is a cycle that fast assertions might miss.

Monitor in production by tracking navigation-failure counts and redirect-chain lengths. A guard regression announces itself as a spike in aborted navigations from logged-out clients hours before support tickets arrive. Alert on it, and the next loop gets fixed in canary instead of becoming an hour-long lockout. Telemetry turns redirect loops from user-reported outages into deploy-time catches.

auth-guard.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
import { test, expect } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import { routes, authGuard } from './router-guarded.js'

test('logged-out /login settles without redirect', async () => {
  localStorage.clear()
  const router = createRouter({ history: createMemoryHistory(), routes })
  router.beforeEach(authGuard)
  await router.push('/login')
  await router.isReady()
  expect(router.currentRoute.value.path).toBe('/login')
})
Try it live
📊 Production Insight
Redirect-chain telemetry catches guard regressions in canary within minutes. Without it, loops are discovered by locked-out users, not by dashboards.
🎯 Key Takeaway
Test every auth path logged-out in fresh profiles, and alert on redirect-chain spikes in production.
● Production incidentPOST-MORTEMseverity: high

The Auth Refactor That Locked Every Logged-Out User Out for an Hour

Symptom
Support reported that logged-out users couldn't reach the login page: the browser spun and eventually showed an error after dozens of rapid URL flickers between /dashboard and /login. Logged-in sessions worked perfectly, so monitors stayed green and the team assumed a client network issue for the first twenty minutes. New signups and expired sessions were all locked out — effectively a full outage for anyone without a live token.
Assumption
The team blamed the identity provider's session endpoint because only logged-out users suffered. They restarted the auth service and rotated keys with no effect. Then they suspected a service-worker caching loop serving stale redirects, and purged the CDN cache. Both theories fit 'only logged-out users' but neither explained URL flickering between two app routes — a purely client-side signature everyone overlooked while chasing infrastructure.
Root cause
That morning's auth refactor added router.beforeEach checking the token store and calling next('/login') for unauthenticated navigations — with no exception for /login itself. Every redirect to /login was a fresh navigation that re-ran the global guard, failed the same token check, and redirected to /login again. A per-route guard on /dashboard also redirected authenticated users away while the global guard pulled strangers back, so edge cases bounced between both routes. Logged-in developers never reproduced it because their tokens skipped the redirect branch entirely.
Fix
Routes now carry meta: { requiresAuth: true } on protected pages only, with login and signup public by default. The global guard checks to.meta.requiresAuth and returns '/login' (Router 4 style) solely for those, preserving the target via query. The redundant per-route dashboard guard was removed so one guard owns the decision. A logged-out Playwright test loads /login, /signup, and a deep protected URL directly and asserts each settles within two navigations.
Key lesson
  • Public routes must be explicit, not accidental. An allow-by-default guard turns every redirect target into a potential loop the moment someone guards it.
  • One guard should own the auth decision. Global plus per-route guards that both redirect create bounce loops no single-file review can see.
  • Test auth flows logged out, in a fresh profile. Developers with live tokens are structurally blind to redirect loops.
Production debug guideFive steps to find the guard that redirects to a route it also guards.5 entries
Symptom · 01
URL flickers between two routes (or itself) and navigation never settles
→
Fix
Open DevTools and watch the address bar: note the exact bouncing pair. Then read your global beforeEach and any beforeEnter on those routes. The loop is the guard whose redirect target also satisfies its own redirect condition — typically /login failing the same auth check. Log to.name on each guard run to see the cycle printed plainly.
Symptom · 02
Loop only affects logged-out users; logged-in sessions work fine
→
Fix
Open an incognito window (no token) and hard-load /login directly. If it bounces, the login route isn't exempted from the auth check. Fix by gating the redirect on route meta (requiresAuth) rather than redirecting every unauthenticated navigation unconditionally.
Symptom · 03
You use next() and suspect double-driving the navigation
→
Fix
Audit the guard for multiple next() calls across branches — every code path must call next exactly once. Look for next('/login') followed by code that falls through to another next(), or next() combined with a return value in Router 4. Restructure to early-return each branch so only one decision executes.
Symptom · 04
Two guards (global plus per-route) seem to fight each other
→
Fix
Map both guards' redirect conditions on paper: global sends strangers to /login, per-route sends the wrong role to /dashboard, and a misclassified user satisfies both in turn. Consolidate into one global guard reading meta fields (requiresAuth, roles), and delete the per-route redirect. One decision-maker can't argue with itself.
Symptom · 05
Redirect works but users land on the homepage instead of their deep link after login
→
Fix
Preserve the target: redirect with query { redirect: to.fullPath } and, after successful login, router.push(redirectQuery || '/'). Test the full journey logged-out: deep URL, to login, through auth, back to the deep URL. A guard that forgets the target technically works but trains users to distrust links.
Router Redirect Loops Compared
Root CauseHow to ConfirmFixPrevention
Global guard redirects unauthenticated users including /loginIncognito hard-load of /login bounces; guard lacks meta checkGate redirect on meta.requiresAuth; public-by-default routesLogged-out navigation tests on every auth PR
next() called twice or mixed with return valuesDouble navigation effects; fallthrough past next('/login')Early-return each branch; one decision per navigationLint against fallthrough after next(); single API style
Global plus per-route guards disagree on edge usersBounce pair spans two guards' targets; no single file shows itConsolidate auth into one global guard reading metaRequire guard-interaction map for new redirecting guards
Login drops the deep-link target after authUsers land on homepage; redirect query missing or unusedPreserve to.fullPath query; validate in-app-only on returnEnd-to-end deep-link journey test in CI
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
router.jsconst routes = [Anatomy of the Loop
router-guarded.jsconst routes = [The Meta-Field Pattern
guard-next.jsfunction authGuard(to, from, next) {next() Misuse in Router 3 Codebases
LoginView.vue<script setup>Preserving the Target So Login Returns Users Home
auth-guard.spec.jstest('logged-out /login settles without redirect', async () => {Proving the Cycle Is Dead

Key takeaways

1
Loops redirect into their own condition
exempt the target and the cycle can't form.
2
Use meta.requiresAuth with public-by-default routes; check matched for nested routes.
3
One navigation gets one decision
early-return next() branches, never mix APIs.
4
Keep auth in a single global guard; other guards handle non-auth concerns.
5
Preserve and validate the post-login target; test the full deep-link journey.
6
Test logged-out in fresh profiles and monitor redirect chains in production.

Common mistakes to avoid

5 patterns
×

Redirecting all unauthenticated traffic without exempting login

Symptom
Logged-out users bounce forever between routes; logged-in developers can't reproduce it.
Fix
Check to.meta.requiresAuth (via matched for nested routes) and redirect only flagged pages.
×

Calling next() twice across branches or mixing next with returns

Symptom
Erratic navigation, duplicate redirects, warnings about navigation guards in Router 4.
Fix
Early-return every branch. Pick one API style per file — return-style for Router 4.
×

Stacking global and per-route auth guards that both redirect

Symptom
Edge-case users bounce between dashboard and login; each guard looks correct alone.
Fix
One global auth guard owns the decision. Per-route guards handle non-auth concerns only.
×

Pushing the redirect query target without validation

Symptom
Login links can bounce users to external URLs — an open-redirect phishing vector.
Fix
Accept only in-app paths (single leading slash). Default to home when absent or invalid.
×

Testing auth only with a live developer token

Symptom
Loops ship to production because the redirect branch never executes in dev or QA sessions.
Fix
Test logged-out in fresh profiles: direct loads of login, signup, and deep protected URLs.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does redirecting unauthenticated users to /login sometimes loop fore...
Q02SENIOR
What's wrong with calling next('/login') and then falling through to nex...
Q03SENIOR
How do Router 3 next() guards differ from Router 4 return guards?
Q04SENIOR
How do you send users back to their deep link after login, safely?
Q05SENIOR
Two individually-correct guards create a loop together. How do you fix i...
Q01 of 05JUNIOR

Why does redirecting unauthenticated users to /login sometimes loop forever?

ANSWER
If the global guard redirects every unauthenticated navigation without exempting /login, the redirect itself re-runs the guard, fails the same check, and redirects again. Fix by gating on meta.requiresAuth so public routes terminate the cycle.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How many redirects before the browser gives up?
02
Should login redirect already-authenticated users away?
03
Does to.meta work for nested routes?
04
Can navigation guards be async?
05
Where should the token check live — guard or store?
06
Why did only logged-out users see the loop?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Vue. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Vue Extraneous Non-Props Attributes Warning
5 / 7 · Vue
Next
Vue Hydration Node Mismatch in SSR / Nuxt
→