The ask, and why it's not a small one
Support needs to see what a customer sees. A landlord admin calls in confused about why a property won't save, and the fastest way to actually help them is to look at their account, not guess from a description over the phone. That's "impersonation" — HQLead temporarily acting as a specific landlord admin.
It sounds like a small feature: an "Impersonate" button and a banner. It isn't. Impersonation is one of the few places in a system where you deliberately let one identity act as another, which means every mistake in the design is a privilege-escalation bug by construction, not an edge case. And this was explicitly meant to be the first of several — the same pattern will need to cover impersonating tenants and other user types later. Get the shape wrong here and every future impersonation feature inherits the mistake.
So before writing any code, the actual questions were:
- How does the borrowed session prove it's borrowed, everywhere that matters?
- How is it different from a real session — shorter-lived? Separately revocable?
- What can it not do, that a real session could?
- How do you prove — after the fact, durably — who impersonated whom, and when?
- How do you build this once so "impersonate a tenant" next year is additive, not a rewrite?
Decision 1: reuse the real token shape, add one claim
The tempting design is a special "impersonation token" with its own verification path. I didn't do that. A borrowed landlord-admin session uses the exact same JWT shape a real landlord-admin login produces:
// tokenService.js
function generateTokens({ actorId, actorType, impersonatedBy }) {
const accessToken = jwt.sign(
{
sub: actorId,
type: "access",
actorType,
...(impersonatedBy ? { impersonatedBy } : {})
},
env.landLordJwtSecret,
{ expiresIn: "15m" }
);
// ...
}
sub is the real target admin's id. Not a synthetic "impersonation
session" id, not the HQLead's id — the actual landlord admin being viewed.
That one choice is what makes the rest of the system not need to change at
all. Every ownership check in the app — "does this property belong to this
landlord admin's landlord" — reads req.user.id and resolves it the normal
way. resolveLandlordIdForActor, the properties module, the units module,
none of them know or care that the token in front of them came from an
impersonation grant instead of a real login. The blast radius of this
feature is almost entirely contained to the auth layer, because the token
lies about who's driving, not about whose data this is.
impersonatedBy is the only tell — a single extra claim carrying the
HQLead's id. Every other piece of the design hangs off that one field being
present or absent.
Decision 2: no refresh token. Ever.
A real login gets a rotating, Redis-backed refresh token so the session can renew itself for as long as the user keeps using it. An impersonation grant gets none of that. It's a flat 15-minute JWT, full stop:
// landlordadminIdentity.controller.js — impersonate()
res.cookie(CSRF_COOKIE, result.csrfToken, csrfCookieOptions(env));
// no refresh cookie is ever set here — contrast with login/refresh,
// which always call setAuthCookies() with both.
When it expires, the frontend's silent-refresh machinery tries the normal
/auth/refresh endpoint out of habit — and it correctly fails, because
there's no refresh cookie for it to send. That's not a bug being tolerated;
it's the point. HQLead has to make a new, explicit, freshly-authorized
decision to keep viewing as someone, every 15 minutes. There's no such
thing as a borrowed identity that quietly renews itself for hours while
nobody's watching.
This also means I didn't have to design any new expiry logic. The JWT's own
exp claim does all the work a bespoke timeout system would have — for
free, because I refused to build the renewal path that would have needed
one.
Decision 3: a separate Redis namespace
A real landlord-admin session lives at session:landlord_admin:<id> in
Redis. An impersonation grant lives at impersonation:<id> — a completely
different key, checked by a completely different branch in the session
middleware:
// verifyLandLordAdminSession.js
const redisKey = req.user.impersonatedBy
? `impersonation:${adminId}`
: `session:landlord_admin:${adminId}`;
This was worth getting right on the first pass, because the wrong version of this feature is one where starting a support session silently logs the customer out of their own account, or where ending a support session accidentally kills the customer's real one. Neither Redis key ever touches the other. A landlord admin can be mid-session on their own laptop while HQLead is impersonating them on a support call, and neither one can see or disturb the other's state.
Decision 4: revocation that's actually instant
Here's the property I most wanted, and the one that took the least new
code to get. Every authenticated request already re-checks Redis via
verifyLandlordAdminSession — that's not new, real sessions have always
worked this way. It means "Stop Impersonating" doesn't have to invalidate a
JWT (you can't — that's the whole point of a signed token). It just has to
delete the Redis key the next request will look for:
// landlordadminAuth.service.js — endImpersonation()
async function endImpersonation({ adminId, hqLeadId }) {
const session = JSON.parse(await redis.get(`impersonation:${adminId}`));
if (session.hqLeadId !== hqLeadId) throw new AppError("NOT_IMPERSONATING", 400);
await impersonationSessionsRepo.markEnded(session.sessionRowId, {
ended_reason: "manual_stop"
});
await redis.del(`impersonation:${adminId}`);
}
The access token is still cryptographically valid for however many minutes
remain on its 15-minute clock. It just doesn't work anymore, because the
session check that gates every request now finds nothing. That's a real
revocation, not "wait it out" — verified directly in the integration test
by calling /auth/me again with the same token immediately after ending
the session and confirming it 401s.
Decision 5: a durable audit trail, not a log line
Every other "HQLead acts on a landlord admin" feature in this codebase —
unlocking an account, forcing a password reset — records itself as a
logger.info line: readable, but not queryable, not reportable on, gone
the moment log retention rotates it out. That's fine for those features.
It is not fine for "who was inside whose account and for how long,"
which is exactly the kind of question that needs to survive being asked
six months later.
So this gets a real table:
pgm.createTable("impersonation_sessions", {
id: { type: "uuid", primaryKey: true, default: pgm.func("gen_random_uuid()") },
hqlead_id: { type: "uuid", notNull: true, references: "hq_leads", onDelete: "restrict" },
target_type: { type: "text", notNull: true },
target_id: { type: "uuid", notNull: true },
started_at: { type: "timestamp", notNull: true, default: pgm.func("now()") },
ended_at: { type: "timestamp" },
ended_reason: { type: "text" },
ip_address: { type: "text" }
});
Two things worth calling out. First, onDelete: "restrict" on the
hqlead_id foreign key — an HQLead account can't be deleted while it has
impersonation history attached, on purpose. An audit trail that
disappears the moment the auditee is removed isn't an audit trail.
Second, and this is the piece meant to outlive today's feature:
target_type/target_id are generic, not landlord_admin_id. Every row
today has target_type = 'landlord_admin', but the column doesn't know
that's the only option. Impersonating a tenant later is a new value in an
existing column, reusing this exact table, this exact repo, this exact
"was this session ended, and why" query shape. Nothing about the table
had to guess at what a tenant impersonation session would look like — it
just had to not assume there'd only ever be one kind.
Decision 6: locking the front door while wearing someone else's key
"HQLead can act on this account" and "HQLead can change this account's security settings" are different permissions, and the feature only grants the first one. A new, three-line middleware enforces the line:
// blockDuringImpersonation.js
export function makeBlockDuringImpersonation() {
return function blockDuringImpersonation(req, res, next) {
if (req.user?.impersonatedBy) {
throw new AppError(
"This action is not available during an impersonated session",
403
);
}
next();
};
}
It sits on exactly four routes: enabling MFA, verifying MFA, disabling MFA, and regenerating backup codes. Everything else — properties, units, the actual day-to-day work a landlord admin does — stays fully available, because that's the entire point of the feature. The line isn't "restrict everything," it's "don't let a support session touch the credentials that control the account being supported." Tested it live: clicking "resume MFA setup" while impersonating surfaces the 403 message directly in the UI, in plain language, not a stack trace.
The frontend: reusing a slice instead of building a second auth system
The trick that made the frontend simple: an impersonation grant is shaped
exactly like a real landlord-admin login response ({accessToken, csrfToken, actor, impersonatedBy}), so it dispatches into the same Redux slice a
real login already uses:
// HQLeadLandlordAdminsPage.jsx
const handleImpersonate = async (admin) => {
const result = await impersonate(admin.id).unwrap();
dispatch(setCredentials(result)); // same action a real login uses
navigate("/landlord-admin/dashboard");
};
HQLead's own session, in its own separate hqleadAuth slice, is never
touched by any of this — clicking Impersonate doesn't log HQLead out of
their own panel, it just also populates the landlord-admin slice sitting
right next to it in the same Redux store. LandlordAdminLayout renders a
banner whenever impersonatedBy is set:
Viewing as demo.landlord@example.com — impersonated by testuser
[Stop Impersonating]
"Stop Impersonating" calls the end-session endpoint, clears the landlord-admin slice, and sends HQLead straight back to their own dashboard — no re-login, because their own tokens were never disturbed.
What broke while building this
The most interesting bug wasn't in the impersonation code at all — it was
in the test suite. impersonation_sessions.hqlead_id being ON DELETE RESTRICT (a deliberate choice, see above) meant the very first test run
after adding the table failed seventeen unrelated tests, all with the
same error:
error: update or delete on table "hq_leads" violates foreign key
constraint "impersonation_sessions_hqlead_id_fkey"
The shared test helper that resets the database between every test does a
blanket DELETE FROM hq_leads — which now fails the instant any test
anywhere in the suite creates an impersonation session, because that
session's audit row is still sitting there insisting the hq_lead it
references can't be removed. The fix was two lines (delete
impersonation_sessions before hq_leads in the reset helper), but it's
a good example of a real, low-severity cost of doing audit integrity
properly: the constraint that protects the audit trail in production is
the same constraint that will bite the first piece of test infrastructure
that doesn't know the new table exists.
What it generalizes to, and what it doesn't yet
Everything above was built once, for one target type. The parts that are
already generic — the audit table, the repo, the Redis key pattern
(impersonation:<targetId> isn't landlord-admin-specific naming) — mean
"impersonate a tenant" is mostly a matter of repeating the same shape
against a different actor/token type, not inventing a new one. What
isn't generic yet, and would need real thought rather than a copy-paste,
is the eligibility gate (what makes a tenant "impersonatable" will be a
different set of checks than "has this landlord admin finished activating
their account") and the frontend entry point (tenants don't have a
HQLeadLandlordAdminsPage-shaped list view to click "Impersonate" from
yet). The pattern is reusable. The specifics per actor type still have to
be decided on purpose, not inherited by accident.
Live link coming soon