Vue Router Guard Redirects to Itself Forever: Loop Fix
A guard that redirects to a route it also guards loops forever.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓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
- 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
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.
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.
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.
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.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.
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.
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.
The Auth Refactor That Locked Every Logged-Out User Out for an Hour
- 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.
next() and suspect double-driving the navigationnext() 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.| File | Command / Code | Purpose |
|---|---|---|
| router.js | const routes = [ | Anatomy of the Loop |
| router-guarded.js | const routes = [ | The Meta-Field Pattern |
| guard-next.js | function 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.js | test('logged-out /login settles without redirect', async () => { | Proving the Cycle Is Dead |
Key takeaways
next() branches, never mix APIs.Common mistakes to avoid
5 patternsRedirecting all unauthenticated traffic without exempting login
Calling next() twice across branches or mixing next with returns
Stacking global and per-route auth guards that both redirect
Pushing the redirect query target without validation
Testing auth only with a live developer token
Interview Questions on This Topic
Why does redirecting unauthenticated users to /login sometimes loop forever?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Vue. Mark it forged?
5 min read · try the examples if you haven't