8.7 KiB
SPA Auth Redirect Loop — Diagnostic Workflow
Symptoms
User visits https://domain/ and the page flickers/refreshes repeatedly, alternating between two URLs (e.g., index.html and login.html). The browser URL bar visibly changes back and forth.
Root Causes (check in order)
-
Host header mismatch — VPS
proxy_set_headersends a Host the home nginx doesn't recognize. Request hits the default server block instead of the correct one. PocketBase/Firebase API calls fail or hit wrong context → auth state flips → redirect loop. -
Stale auth token in localStorage — PocketBase JS SDK reads an expired token from
localStorage, initially considers it valid (JWT not yet expired), but the first API call returns 401 → authStore clears → redirect fires. On the next page load, the token is read from localStorage again → cycle repeats. -
Bounce-back doesn't clear the stale token — Even after the architecture is fixed (one-way redirects), the protected pages'
onAuthStateChanged(null)handlers redirect to the login page WITHOUT clearing the stalepocketbase_authfrom localStorage. The login page's inline check (if(localStorage.getItem('pocketbase_auth'))) fires again → redirects back → infinite loop. Fix: addlocalStorage.removeItem('pocketbase_auth')immediately beforewindow.location.href = 'index.html'in every protected page's auth-failure path. Seereferences/auth-redirect-loop.mdin theshop-pro-quoteskill for the full file list. -
Browser-cached 302 redirect — after fixing the server, the user still sees the old loop because their browser cached the 302 redirect from before the fix.
curl -skIreturns 200, but the browser shows a redirect.
Diagnostic Steps
0. FULL-TEXT GREP FIRST (do not skip this)
Before looking at individual files or nav links, grep EVERY JS file for all redirect sources at once. The redirect can be buried anywhere — an auth listener, a guard function, a keyboard shortcut handler, an inline script. Nav links are the most visible but often NOT the root cause.
# Find ALL redirect sources in one sweep
grep -rn "window\.location\|location\.replace\|location\.href\|meta.*refresh" --include="*.js" --include="*.html" .
Missing a hidden onAuthStateChanged redirect is the #1 diagnostic failure in SPA redirect loops. The fix may look deceptively simple (rename files, update nav links) but the real loop lives in auth listeners that fire on state changes.
1. Check what the server actually returns
curl -skI https://domain/
# HTTP/1.1 200 OK → server is fine, problem is client-side
# HTTP/1.1 302 + Location: login.html → server still has old redirect
2. Check if the target file exists
curl -skI https://domain/login.html
# 404 → file was deleted, redirect is cached
# 200 → file still exists, redirect is real
3. Check VPS ↔ Home Host header alignment
# On VPS: what Host header goes to home?
grep "proxy_set_header Host" /etc/nginx/sites-enabled/<service>
# On home: what server_name does nginx expect?
grep "server_name" /etc/nginx/sites-enabled/* /etc/nginx/sites-available/*
4. Check for stale PocketBase auth token
Open browser DevTools → Application → Local Storage → look for pocketbase_auth key. If present, the SDK will try to use it on page load. If the token is expired server-side but not client-side, this creates the loop.
Fix Patterns
Pattern A: Server alias (preferred)
Add the VPS domain as a server_name alias on the home nginx config:
sudo sed -i 's/server_name grajmedia.duckdns.org;/server_name grajmedia.duckdns.org <vps-domain>;/' /etc/nginx/sites-enabled/<config>
sudo nginx -t && sudo systemctl reload nginx
Allows Host: <vps-domain> to reach the correct server block without changing the VPS config.
Pattern B: Fix Host header on VPS
proxy_set_header Host grajmedia.duckdns.org;
Matches what the home nginx expects. Only affects one service.
Pattern C: File rename — swap index and login
When the root index.html is the dashboard (auth-guarded) and login.html is separate, visiting / triggers: load dashboard → no auth → redirect to login.html → has auth → redirect to index.html → loop.
Fix: Make index.html the login page, rename dashboard to dashboard.html, update ALL references:
# 1. Backup
cp login.html _login.html.bak && cp index.html _index.html.bak
# 2. Swap
cp login.html index.html && cp _index.html.bak dashboard.html && rm login.html
# 3. Update JS references (grep first to find them all)
grep -rn "login\.html\|index\.html" --include="*.js" --include="*.html" .
# 4. Bulk-fix auth redirects: login.html → index.html
grep -rl "'login\.html'" --include="*.js" . | xargs sed -i "s/'login\.html'/'index.html'/g"
# 5. Bulk-fix nav links: index.html → dashboard.html
grep -rl 'href="index\.html"' --include="*.html" . | xargs sed -i 's|href="index\.html"|href="dashboard.html"|g'
# 6. Fix login-redirect targets: index.html? → dashboard.html?
sed -i "s/'index\.html?/'dashboard.html?/g" login.js # after-login redirect
Full file list from a real fix (13 files, 28 references):
dashboard.js,login.js,main.js,appointments.js,repair-orders.js,customers.jsshared/header-functionality.jsdashboard.html,appointments.html,repair-orders.html,customers.htmlindex.html(the new login page — inline redirect scripts)
Pattern D: Stub page cache-breaker
When the browser has a cached 302 redirect to a now-deleted file, put a minimal stub back that redirects to the correct page with no-cache headers:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta http-equiv="refresh" content="0; url=index.html">
<title>Redirecting…</title>
</head>
<body><p>Redirecting… <a href="index.html">click here</a> if not redirected.</p></body>
</html>
This breaks the cached redirect: the browser loads the stub, which instantly takes them to the correct page. After a few visits the cache clears naturally.
Pattern E: Remove auto-redirect from login page's onAuthStateChanged
When BOTH the login page and the dashboard have onAuthStateChanged listeners that redirect to each other, any auth-state instability creates a loop:
dashboard.js: onAuthStateChanged(null) → redirect to login.html
login.js: onAuthStateChanged(user) → redirect to index.html
→ LOOP
Fix: Patch the login page so onAuthStateChanged only manipulates UI (show/hide elements), never redirects. Only explicit login/signup button click handlers should redirect. Use an inline <script> at the top of the login page body to handle the "already logged in" redirect instead:
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('dashboard.html?cb='+Date.now())}</script>
This localStorage check fires before any modules load, has no auth-state dependency, and never causes a loop. It delegates the "already logged in" redirect to a simple token presence check, while login.js's onAuthStateChanged becomes UI-only.
Worked Example: ShopProQuote
Domain: grajmedia.duckdns.org (VPS proxied to home:443)
Backend: PocketBase at 127.0.0.1:8091
Problem: Infinite redirect between index.html (dashboard) and login.html, later between login.html → dashboard.html → index.html
Root causes found (all three were active simultaneously):
shopproquote.graj-media.comVPS config sentHost: graj-media.combut home nginx only hadserver_name grajmedia.duckdns.org→ Host header mismatch → request landed on wrong server blocklogin.js'sonAuthStateChangedauto-redirected todashboard.htmlon auth state — coupled with dashboard.js's redirect back toindex.htmlon null, any PocketBase auth instability created a loop. Also fired ononAuthStateChangedregistration which could race with the initial state.- After all server-side fixes, browser had cached 302 redirects from the old setup
Fix applied (order matters — do all three):
- File swap (Pattern C):
index.html→ login page,dashboard.html→ dashboard, 28 references across 13 files updated - Patch
login.js(Pattern E):onAuthStateChanged→ UI-only (shows/hides form vs loading), no redirect. Only explicit login/signup + inline localStorage check redirect. - Server alias (Pattern A): added
graj-media.com shopproquote.graj-media.comto home shopproquote nginxserver_name - Stub page (Pattern D): created minimal
login.htmlwith no-cache redirect toindex.htmlto break cached browser redirects, then deleted after cache cleared