Files
hermes-config/skills/vps-reverse-proxy/references/spa-redirect-loop.md
T
2026-07-12 10:17:17 -04:00

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)

  1. Host header mismatch — VPS proxy_set_header sends 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.

  2. 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.

  3. 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 stale pocketbase_auth from localStorage. The login page's inline check (if(localStorage.getItem('pocketbase_auth'))) fires again → redirects back → infinite loop. Fix: add localStorage.removeItem('pocketbase_auth') immediately before window.location.href = 'index.html' in every protected page's auth-failure path. See references/auth-redirect-loop.md in the shop-pro-quote skill for the full file list.

  4. 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 -skI returns 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.js
  • shared/header-functionality.js
  • dashboard.html, appointments.html, repair-orders.html, customers.html
  • index.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.htmldashboard.htmlindex.html

Root causes found (all three were active simultaneously):

  1. shopproquote.graj-media.com VPS config sent Host: graj-media.com but home nginx only had server_name grajmedia.duckdns.org → Host header mismatch → request landed on wrong server block
  2. login.js's onAuthStateChanged auto-redirected to dashboard.html on auth state — coupled with dashboard.js's redirect back to index.html on null, any PocketBase auth instability created a loop. Also fired on onAuthStateChanged registration which could race with the initial state.
  3. After all server-side fixes, browser had cached 302 redirects from the old setup

Fix applied (order matters — do all three):

  1. File swap (Pattern C): index.html → login page, dashboard.html → dashboard, 28 references across 13 files updated
  2. Patch login.js (Pattern E): onAuthStateChanged → UI-only (shows/hides form vs loading), no redirect. Only explicit login/signup + inline localStorage check redirect.
  3. Server alias (Pattern A): added graj-media.com shopproquote.graj-media.com to home shopproquote nginx server_name
  4. Stub page (Pattern D): created minimal login.html with no-cache redirect to index.html to break cached browser redirects, then deleted after cache cleared