Support asks "what does this customer see?" and the honest answer is to sign in as them. Clerk and Better Auth have that built in. Supabase Auth does not, and the shortcuts people reach for each leak something:
- Asking the user for their password. No.
- A cookie or query flag that says "impersonating". Anyone can set a cookie in their own browser. If a flag in the browser decides whether the app shows a banner or blocks account changes, a user can turn it off.
- Minting a JWT with the project's secret. It works, and it means your app server holds the key that can sign a token for anyone, forever.
What does work uses two service-role calls and one marker.
1. Sign the admin's browser in as the user, on the server
generateLink creates a magic-link token without sending an email.
verifyOtp with its hash signs in whoever calls it. Call both from a server
action, so the token never reaches the browser:
"use server";
export async function startImpersonation(targetId: string) {
const actor = await requireRole("admin"); // your own guard
const admin = createAdminClient(); // service role, server only
const { data: target } = await admin.auth.admin.getUserById(targetId);
if (!target.user?.email) throw new Error("User has no email.");
const { data: link, error } = await admin.auth.admin.generateLink({
type: "magiclink",
email: target.user.email,
});
if (error) throw error;
const supabase = await createServerSupabase(); // cookie-writing client
await supabase.auth.signOut({ scope: "local" }); // end the admin's own session
const { data } = await supabase.auth.verifyOtp({
type: "magiclink",
token_hash: link.properties.hashed_token,
});
const sessionId = sessionIdFromAccessToken(data.session?.access_token);
await admin.auth.admin.updateUserById(targetId, {
app_metadata: { impersonation: { by: actor.id, session_id: sessionId } },
});
// write an audit log row here
}
The browser now holds a normal session for the target user. Nothing about it says "impersonation" yet. That is the marker's job.
2. The marker lives in app_metadata, bound to a session id
{ "impersonation": { "by": "<admin id>", "session_id": "<the new session>" } }
Two properties make it trustworthy:
- Only the service role writes
app_metadata. A signed-in user can changeuser_metadatafrom the browser, but not this. So nobody can mark their own session as impersonated, or clear the mark to hide it. - It names one session. Every Supabase access token carries a
session_idclaim. The marker counts only when the current session's id matches. The real user, signed in on their own phone at the same moment, has a different session id, so they never see the banner or lose the ability to change their password.
Reading it:
export function impersonatedByFor(appMetadata: unknown, sessionId: string | null) {
const marker = readMarker(appMetadata); // validates both fields are strings
if (!marker || !sessionId) return null;
return marker.sessionId === sessionId ? marker.by : null;
}
sessionId comes from decoding the access token that getUser() has just
verified. Decoding without verifying is fine only because of that order.
Only pay for the decode when a marker exists: most users never have one, and
getUser() already returned app_metadata.
3. Put it on the session user and act on it
Expose it as a plain field (impersonatedBy: string | null) on whatever your
app calls the current user. Then:
- The layout shows a banner while it is set, with a Stop button.
- Every account-changing server action refuses. Password, email, linked identities, "sign out other devices". An admin viewing as someone must not be able to change how that person signs in. Check it in the action, on the server, not only by disabling buttons.
- The audit log records both ids on anything done while it is set.
4. Give the session a hard end, in Supabase Auth itself
Supabase sessions last until they are signed out, and a time limit enforced
only by your banner depends on the admin's browser loading a page. GoTrue
already honours a per-session end: auth.sessions.not_after. Past it, the
refresh token is refused ("Session Expired"). Set it right after verifyOtp,
with a service-role function:
create or replace function public.admin_limit_session(target uuid, only_session uuid, minutes integer)
returns timestamptz
language plpgsql
security definer
set search_path = ''
as $$
declare
ends_at timestamptz;
begin
update auth.sessions
set not_after = least(
coalesce(not_after, 'infinity'::timestamptz),
coalesce(created_at, now()) + make_interval(mins => greatest(minutes, 1))
)
where user_id = target and id = only_session
returning not_after into ends_at;
return ends_at;
end;
$$;
revoke all on function public.admin_limit_session(uuid, uuid, integer) from public, anon, authenticated;
grant execute on function public.admin_limit_session(uuid, uuid, integer) to service_role;
If it fails, sign the browser out and fail the start. An impersonation with no end is worse than none.
5. Stopping
Sign that one session out with scope: "local", then delete it by its id with
the service role as well: the browser's own sign-out only works while its
access token is valid, and a refresh token copied meanwhile must die too.
Clear the marker (impersonation: null) only once the session is gone. If
the delete fails, keep the marker: the session stays read-only in your app,
and the next start deletes it. The admin then signs in again as themselves:
their own session was ended in step 1, and there is no safe way to stash and
restore it. Say so in the confirm dialog before they start.
Caveats to put in the confirm dialog
- An unconfirmed email becomes confirmed. Supabase treats a verified magic link as proof of the address.
- Account changes are off in this app, not in Supabase. Supabase Auth has
no read-only session. Until the session ends, its tokens sit in the admin's
browser (the
@supabase/ssrcookies are readable by the page, because the browser client needs them) and can call Supabase Auth directly: change the password, the email, the linked identities. Your actions refusing is a guard against mistakes, not against an admin who goes around the app. Say so. - The end is not instant for a copied token.
not_afterstops refreshes and Stop deletes the session, which Supabase Auth then refuses for its own endpoints. But an access token is a signed JWT, and code that only checks the signature (PostgREST, your RLS) accepts it until it expires, an hour by default. Keep the JWT expiry short if that matters to you.
Why a marker in app_metadata and not an httpOnly, signed cookie: a cookie
lives in the browser the admin controls, and signed or not, deleting it is
always possible. The marker lives in the user record, only the service role
can write it, and it names the session, so there is nothing in the browser to
remove.
Test it
- Unit-test
impersonatedByForwith no marker, a marker for another session, a malformed marker, and a match. It is pure. - Start an impersonation, then sign in as the real user in another browser: no banner there, and they can still change their password.
- As the impersonating admin, post the change-password form with curl: the action refuses.
- Read
not_afterfor the new session inauth.sessions: an hour aftercreated_at. Move it into the past and refresh: Supabase refuses. - Stop, then look the session id up in
auth.sessions: gone. - Try
updateUser({ data: { impersonation: null } })from the browser: it changesuser_metadata, notapp_metadata, and the banner stays.