Cross-Tenant Admin Impersonation Safeguards
Support impersonation is the one feature that deliberately crosses the tenant boundary, and this page shows how to build it so that crossing is bounded, consented, visible, and provable. It is a focused part of RBAC Per Tenant, which otherwise assumes no principal ever acts outside its own tenant.
Problem Framing
Every SaaS platform eventually needs a support engineer to see what a customer sees. The naive implementation — a staff flag that swaps the session's tenant_id — creates a permanent, unlogged, unlimited backdoor into every customer's data. It is also the implementation that most incident write-ups describe, because it is trivially easy and nothing about it fails until it is abused.
Four properties separate a safe impersonation feature from a backdoor. It must be bounded: a specific staff identity, acting as a specific user, in a specific tenant, for a bounded time. It must be attributable: every resulting action records both the acting staff identity and the subject user, never collapsing the two. It must be visible: the impersonated user's organisation can see that it happened, ideally in real time. And it must be least-privileged by default: read-only unless a write was separately justified.
The mechanism that delivers all four is a short-lived impersonation grant that produces a distinct token type, carrying both identities as separate claims. That token is what downstream services and the database see, so the distinction survives every hop rather than being reconstructed from a log line.
Step-by-Step Guide
1. Model the grant, not the session
An impersonation grant is a first-class record with an expiry, a reason, and an explicit write flag. Sessions derive from it; it is never derived from a session.
CREATE TABLE impersonation_grant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id uuid NOT NULL, -- the staff identity
tenant_id uuid NOT NULL, -- the tenant being entered
subject_id uuid NOT NULL, -- the user being impersonated
reason text NOT NULL,
ticket_ref text NOT NULL,
allow_writes boolean NOT NULL DEFAULT false,
approved_by uuid, -- NULL only for consent-based grants
consented_by uuid, -- tenant-side approver, when used
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
CONSTRAINT bounded_window CHECK (expires_at <= created_at + interval '2 hours'),
CONSTRAINT authorised CHECK (approved_by IS NOT NULL OR consented_by IS NOT NULL)
);
The two constraints do most of the work: no grant can outlive two hours, and no grant exists without either a second staff approver or explicit tenant-side consent.
2. Mint a token that carries both identities
Reusing the ordinary session token with a swapped subject destroys attribution. Issue a distinct token type whose claims name the actor and the subject separately, following the standard act claim shape so downstream services need no bespoke parsing.
// mint-impersonation-token.ts
import { SignJWT } from 'jose';
export async function mintImpersonationToken(grant: Grant, key: CryptoKey) {
return new SignJWT({
sub: grant.subjectId, // who the request appears to be
act: { sub: grant.actorId }, // who is actually driving — RFC 8693 style
tid: grant.tenantId,
scope: grant.allowWrites ? 'impersonate:rw' : 'impersonate:ro',
gid: grant.id,
})
.setProtectedHeader({ alg: 'EdDSA', kid: 'imp-2026-07' })
.setIssuedAt()
.setExpirationTime(Math.floor(grant.expiresAt.getTime() / 1000))
.sign(key);
}
Signing impersonation tokens with a key distinct from ordinary session keys means a leaked application key cannot mint one; the rotation mechanics for both are covered in rotating tenant-specific JWT signing keys.
3. Enforce read-only at the database, not in the handler
A read-only flag checked in application code is one forgotten branch away from a write. Bind the transaction itself to read-only when the grant does not allow writes, so the database refuses.
// scoped-impersonated-tx.ts
export async function runImpersonated<T>(pool: Pool, ctx: ImpCtx, work: (c: PoolClient) => Promise<T>) {
const client = await pool.connect();
try {
await client.query('BEGIN');
if (!ctx.allowWrites) await client.query('SET TRANSACTION READ ONLY');
await client.query('SELECT set_config($1, $2, true)', ['app.current_tenant_id', ctx.tenantId]);
await client.query('SELECT set_config($1, $2, true)', ['app.acting_staff_id', ctx.actorId]);
const out = await work(client);
await client.query('COMMIT');
return out;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
app.acting_staff_id is what lets audit triggers record the real actor without the application having to remember to pass it.
4. Record actor and subject as separate audit columns
Collapsing them — writing the subject as the actor — is the mistake that makes an audit log actively misleading, because it attributes staff actions to the customer's own employee.
CREATE OR REPLACE FUNCTION audit_row_change() RETURNS trigger AS $$
BEGIN
INSERT INTO audit_event (tenant_id, subject_id, acting_staff_id, action, entity, entity_id, at)
VALUES (
current_setting('app.current_tenant_id', true)::uuid,
current_setting('app.current_user_id', true)::uuid,
nullif(current_setting('app.acting_staff_id', true), '')::uuid,
TG_OP, TG_TABLE_NAME, NEW.id, now()
);
RETURN NEW;
END; $$ LANGUAGE plpgsql;
5. Make it visible to the customer while it is happening
Notification after the fact is a disclosure; notification during is a control, because a tenant admin who did not expect the session can revoke it. Emit a real-time signal at grant creation and expose an active-sessions view inside the tenant's own admin area.
// notify.ts
export async function announceImpersonation(grant: Grant) {
await bus.publish(`tenant.${grant.tenantId}.security`, {
type: 'impersonation.started',
actorDisplay: await staffDisplayName(grant.actorId),
subjectId: grant.subjectId,
reason: grant.reason,
ticketRef: grant.ticketRef,
expiresAt: grant.expiresAt.toISOString(),
revokeUrl: `/settings/security/support-sessions/${grant.id}`,
});
await email.toTenantAdmins(grant.tenantId, 'support-session-started', grant);
}
The per-tenant policy that governs whether a grant can be created at all is worth showing as a matrix, because the three modes differ in who holds the veto rather than in what the session can do.
Verification
Verify the three properties that actually fail in production: writes are impossible under a read-only grant, the audit trail names the staff actor, and the grant genuinely expires.
-- 1. A read-only grant cannot write, even if a handler tries.
BEGIN;
SET TRANSACTION READ ONLY;
SELECT set_config('app.current_tenant_id', '…-a001', true);
INSERT INTO invoice (tenant_id, amount_cents) VALUES ('…-a001', 100);
-- expected: ERROR: cannot execute INSERT in a read-only transaction
ROLLBACK;
-- 2. Actions during impersonation name the staff actor, not just the subject.
SELECT action, entity, subject_id, acting_staff_id
FROM audit_event
WHERE tenant_id = '…-a001' AND acting_staff_id IS NOT NULL
ORDER BY at DESC LIMIT 5;
-- 3. No grant outlives its window; the constraint plus this query prove it.
SELECT count(*) AS live_expired_grants
FROM impersonation_grant
WHERE revoked_at IS NULL AND expires_at < now()
AND EXISTS (SELECT 1 FROM active_session s WHERE s.grant_id = impersonation_grant.id);
-- expected: 0
The third query is the one that catches the common bug: the grant expires but the session minted from it keeps working because nothing re-checks. Sessions must validate the grant on every request, not only at creation.
Failure Modes & Gotchas
-
Symptom: an audit log shows a customer's own employee performing actions they deny. Cause: impersonated actions were recorded under the subject only. Fix: store
acting_staff_idas a separate column and surface it in every customer-facing audit view. -
Symptom: a support session still works an hour after its grant expired. Cause: the session token was validated once at creation and never re-checked against the grant. Fix: validate
gidagainst the grant table on every request, or cap token lifetime below the grant's. -
Symptom: staff routinely request write access "just in case". Cause: escalation is as easy as the initial grant, so it becomes the default. Fix: require a distinct approval for writes, with its own reason, and report the read-to-write ratio monthly.
-
Symptom: a tenant discovers support sessions only in a quarterly report. Cause: notification is batched rather than live. Fix: publish a real-time event and expose an active-sessions panel the tenant can act on.
FAQ
Should customers be able to disable impersonation entirely? Enterprise and regulated customers will ask for exactly this, and the right answer is a per-tenant policy with three settings: always allowed, allowed with tenant approval, and disabled. Disabling it has a genuine support cost — some issues cannot be diagnosed from logs alone — so make that trade-off explicit in the setting's description rather than hiding it. The consent-required mode is the one most customers actually want.
Does the impersonating staff member need the subject's permissions or their own? The subject's, intersected with the grant's scope. The point of impersonation is to reproduce what the user sees, so inheriting the subject's role is correct; the grant then narrows it further, typically to read-only. What must never happen is the union of both identities' permissions, which would let a support engineer perform actions the customer's own user could not.
How long should an impersonation window be? Short enough that it expires before anyone forgets about it — fifteen to thirty minutes is workable, with explicit renewal that re-records a reason. A two-hour hard ceiling in the schema prevents the gradual creep toward "one working day" that turns a bounded grant back into a standing backdoor.
Can impersonation be used to reproduce a bug that only affects one user? That is its most common legitimate use, and it is also the case where read-only is almost always sufficient. Reproducing a rendering fault, an unexpected empty list, or a permission the customer believes they should have requires seeing what they see, not changing anything. Reserve the write escalation for the narrower situation where the fix itself has to be applied inside the tenant — correcting a mis-set configuration value, for instance — and record which of the two the session was for, because the ratio between them is a useful signal about whether the default is calibrated correctly.