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