The gap wasn't the backend

Before this pass, landlordadminAuth.service.js already did the hard part correctly: refresh tokens were hashed before storage (never kept in plaintext, anywhere), rotated on every use, and mirrored into a Redis session so a token could be revoked server-side without waiting for it to expire. That's real infrastructure — the kind of thing that's easy to skip on a personal project and easy to regret skipping later.

None of it was reachable. The access token and refresh token both lived in a Redux slice — plain JavaScript memory — with nowhere durable to persist across a reload. Refresh the tab, and the only thing standing between "logged in" and "logged out" was gone, even though the backend session those tokens pointed at was still perfectly valid in Redis. Every session was, in practice, exactly as long as a single tab stayed open. That's not a hardening gap so much as a completely unused feature — the durability existed on one side of the wire and nowhere on the other.

Why the fix isn't "put it in localStorage"

The tempting fast fix — persist the refresh token to localStorage and rehydrate Redux from it on load — solves the reload problem and creates a worse one: localStorage is readable by any JavaScript running on the page, including an attacker's, if this app ever has an XSS bug anywhere. A stolen refresh token from localStorage is a stolen session with no browser-level protection standing in the way at all.

The actual fix was to give the refresh token a home the client-side JavaScript can't read in the first place: an httpOnly cookie. The browser attaches it automatically on same-site requests; no JS on either end ever holds its value directly.

export function refreshCookieOptions(env) {
  const isProd = env.nodeEnv === "production";
  return {
    httpOnly: true,
    secure: isProd,
    sameSite: isProd ? "none" : "lax",
    maxAge: 7 * 24 * 60 * 60 * 1000
  };
}

sameSite isn't a constant, because dev and prod are genuinely different network topologies. localhost:5173 and localhost:6060 share a registrable domain — different ports, same site — so "lax" still lets the cookie ride along in dev, over plain HTTP. Production puts the frontend on Vercel and the backend on Render: different top-level domains entirely, which browsers treat as cross-site. That requires "none" — and cross-site cookies are only honored at all when secure: true, which requires HTTPS, which is exactly what both platforms give you by default. Get this pairing wrong in either direction and cookies just silently stop showing up on requests, with no error to point at why.

The part that couldn't just follow the refresh token into a cookie

CSRF tokens have the opposite requirement: the client-side JavaScript has to be able to read one, because the whole double-submit-style pattern depends on the app echoing it back in a request header, where an attacker's cross-site form submission can't reach:

export function csrfCookieOptions(env) {
  return { httpOnly: false, /* ...same sameSite/secure logic */ };
}

httpOnly: false looks like a downgrade sitting next to the refresh cookie's true, but it's the correct value for what a CSRF token is for — its security doesn't come from being secret, it comes from an attacker's page having no way to attach a custom header to a cross-site request the way the real frontend can. The server still never trusts the cookie's value directly:

export function makeVerifyLandlordAdminCSRF() {
  return function verifyLandlordAdminCSRF(req, res, next) {
    const csrfHeader = req.headers["x-csrf-token"];
    if (!csrfHeader) throw new AppError("Missing CSRF token", 403);
    if (!req.session) throw new AppError("Unauthorized", 401);
    if (csrfHeader !== req.session.csrfToken) {
      throw new AppError("Invalid CSRF token", 403);
    }
    next();
  };
}

The cookie is just a delivery mechanism to get the token into the browser so JS can read it back out. The check that matters compares the request header against the value stored in the Redis session — the same session the refresh token is hashed against — not against the cookie itself. That ties CSRF validity to a live, revocable, server-authoritative session instead of a value that only ever proves "this browser saw this cookie once."

Identity has to travel differently when the access token can't be trusted

A refresh call is, by definition, the one request in this whole system where the access token is expected to be expired — that's the reason it's being called. So refresh can't authenticate the caller the normal way. The refresh cookie's value isn't just the token — it's <adminId>.<refreshToken>, and the server recovers the identity by splitting on the delimiter, not by decoding a JWT that's already dead:

const cookieValue = req.cookies?.[REFRESH_COOKIE];
const [adminId, refreshToken] = cookieValue.split(".");

Everything downstream still checks the token for real — refresh() re-loads the identity, re-runs landlord lifecycle enforcement, and compares the presented refreshToken against the hashed value stored in Redis via bcrypt-style comparison, not a plain string match:

const match = await passwordService.comparePassword(
  refreshToken,
  session.refreshTokenHash
);
if (!match) throw new AppError("INVALID_REFRESH", 401);

The id in the cookie only says who's asking; the hash comparison is what actually decides whether to believe them. On success, both the refresh token and its hash rotate — the old one stops being valid the moment a new one is issued, whether or not it had expired yet.

A small inconsistency that was worth fixing precisely because it was small

HQLead's access token TTL didn't match landlord_admin's — a leftover from the two auth systems evolving separately rather than a deliberate choice. Nothing exploitable, nothing a test would catch, just two numbers that should have been the same value and weren't. Fixed to 15 minutes on both. The interesting part isn't the fix, it's why it's easy to miss: two parallel auth systems built at different times will drift on details like this by default, silently, unless something forces them to share a definition. authCookies.js exists for the same reason — both refreshCookieOptions and csrfCookieOptions are shared functions, not duplicated per auth system, specifically so sameSite can't quietly diverge between HQLead and landlord_admin the way the TTL did.

What this bought, verified live

Logged in as a landlord admin, reloaded the tab — still authenticated, no re-login. Let the access token expire naturally, made a request — baseQueryWithRefresh.js caught the 401, silently exchanged the httpOnly refresh cookie for a new access token, and retried the original request without the user ever seeing a login screen. That round trip is the entire point: the Redis-backed session machinery that already existed is now actually reachable across a reload, not just reachable within a single tab's lifetime.

Live link coming soon