Skip to content

impersonating-users-safely

Admin panel7 min readships at docs/solutions/admin-panel/impersonating-users-safely.md

A customer writes "the export button does nothing". You can ask for screenshots for a day, or you can look at the app as them for a minute. Impersonation is that minute. It is also a way for one compromised admin account to become every account, so it needs more care than any other admin feature.

Five rules that hold whatever the provider

  1. Never impersonate another admin. Viewing as an admin is a privilege escalation for whoever holds the weaker admin account, and it hides the real actor behind a second one. Refuse it on the server, not only in the menu.
  2. Never impersonate yourself or a banned account. The first is a no-op that confuses the audit trail; the second lets a ban be bypassed.
  3. Time-box it. One hour is plenty for support. A session that lasts as long as a normal one turns a quick look into an unattended back door.
  4. Show it on every page. A banner above the app, "You are signed in as Ana. Stop impersonating", with the stop button in it. An admin who forgets they are someone else will change that person's settings.
  5. Audit both ends. admin.impersonation.started when it begins, admin.impersonation.stopped when it ends, each with the admin and the target. Anything the admin does in between happens as the target, so these two rows are what tie it back to a person. An impersonation that runs out its time ends with no stop entry, so put the time limit in the start entry: the log then still says when it ended at the latest.

While impersonating, the session belongs to the target, so role checks close the admin panel by themselves. Also refuse the few things nobody should do on someone else's behalf: changing their password, deleting their account, paying or cancelling, linking a new sign-in method. Check the session's "impersonated by" marker in those actions.

Then remember that your actions are not the only door. The session is a real session for the user, and the auth provider's own API accepts it. Lock that door where the provider lets you, and say plainly in the confirm dialog where it does not.

What each provider actually does

The mechanics differ a lot, and the differences decide what your confirm dialog has to say.

Better Auth (admin plugin)

  • auth.api.impersonateUser({ body: { userId }, headers }) creates a new session for the target with impersonatedBy set to the admin's id, and stores the admin's own session in a signed admin_session cookie.
  • Default length: 1 hour (impersonationSessionDuration, in seconds). Set it anyway, from the same constant your confirm dialog prints, so an upgrade that changes the default cannot make the dialog lie.
  • Admins cannot be impersonated unless a role holds the impersonate-admins permission (or the deprecated allowImpersonatingAdmins is on). The plugin's built-in admin role leaves it out today. Spell your admin role's permissions out with roles: { admin: ... } rather than inheriting that list, so a new version cannot widen it.
  • auth.api.stopImpersonating({ headers }) deletes the impersonation session and restores the admin's from the cookie. The admin lands back where they were, still signed in.
  • It needs the admin's own session to still exist. When that session expired, was signed out everywhere, or the cookie is gone, it throws a 500 and leaves the browser signed in as the user. Catch it, call auth.api.signOut on the same headers, and send the admin to sign in again. Otherwise Stop fails the same way on every click.
  • From a Next.js server action, the nextCookies() plugin must be last in the plugin list, or the cookies never reach the browser.
  • The plugin does not make the impersonation session read-only. curl with its cookie reaches /api/auth/change-password, /update-user, /revoke-other-sessions and the rest like the user's own session would, and /list-sessions returns the user's other session tokens. Close it in the auth layer: a hooks.before that reads the session and, when impersonatedBy is set, allows only an explicit list (get-session, list-accounts, sign-out, stop-impersonating) and throws APIError("FORBIDDEN") for everything else. The Better Auth battery's guarding-the-auth-api-itself.md has the code.

Clerk (actor tokens)

  • clerkClient.actorTokens.create({ userId, actor: { sub: adminId }, expiresInSeconds: 60, sessionMaxDurationInSeconds: 1800 }) returns a URL. Following it signs the browser in as the user, with the admin's id in the session's act claim (auth().actor.sub).
  • It signs out whoever was signed in first. The admin signs in again after stopping, and stopping itself is a client-side signOut().
  • Sessions last up to 30 minutes, or 10 when idle.
  • It is metered: Clerk's free plan allows 5 impersonations a month. Say so in the dialog, and show Clerk's own error when the quota runs out.
  • What Clerk enforces: the act claim is in the signed token, so the browser cannot add or strip it; actor tokens are single use, expire, and can be revoked before use; the session ends at its maximum length or after 10 idle minutes; impersonation sessions are logged in Clerk.
  • What it does not promise: Clerk's docs do not list which account changes an actor session is refused. Its Frontend API has an impersonated_session_forbidden error, but not a documented list of the actions behind it, and nothing says <UserProfile /> hides its forms for an actor session. So render read-only summaries in place of Clerk's profile components while act is set, refuse account changes in your own actions, and assume the session in the admin's browser can still reach Clerk's own API until it ends.

Supabase Auth (built by hand)

Supabase has no impersonation. The honest version uses two service-role calls, all on the server:

const { data: link } = await admin.auth.admin.generateLink({ type: "magiclink", email });
await supabase.auth.signOut({ scope: "local" });              // end the admin's session
const { data } = await supabase.auth.verifyOtp({
  type: "magiclink",
  token_hash: link.properties.hashed_token,                    // never sent by email
});

The browser never holds a sign-in link or a token, which is better than kits that pass the link to the client. Do it in a server action that ends with redirect("/dashboard"), not one that returns a URL for the browser to follow. An action that changes cookies makes Next.js render the current page again as the new session, which for an admin page means "no access" before the browser can move. A redirect renders the destination instead.

The trade-offs, which the confirm dialog should state:

  • The admin's own session ends. They sign in again after stopping.
  • Verifying a magic link proves the address to Supabase, so an unconfirmed email becomes confirmed.
  • There is no "impersonated by" claim. Mark the new session yourself: write { by: adminId, session_id } into the target's app_metadata (only the service role can write it), and treat the session as impersonated only while its session_id matches. The real user's own sessions never match, so they never see the banner.
  • There is no time limit either, but Supabase Auth honours one per session: auth.sessions.not_after. Set it to an hour after the session started (a service-role SQL function), and Supabase refuses to refresh the session past it even if nobody loads your app again. Your banner still ends it on time, and Stop deletes the session by its id with the service role, not only through the browser's own sign-out.
  • There is no read-only session. Your app can refuse every change while the marker is set, but GoTrue will not: until the session ends, the tokens in the admin's browser can change the password, email or linked identities through Supabase directly. An access token issued before the end works until it expires (the JWT expiry, 1 hour by default). Put that in the dialog.

Checking your work

  • Try to impersonate an admin through the server action directly, with the menu bypassed: refused.
  • Start, look at the dashboard: the banner is on every page.
  • Open /admin while impersonating: not there.
  • Stop: back to the admin panel (or sign-in, for Clerk and Supabase), banner gone.
  • The audit log has exactly one started and one stopped entry, both naming the admin and the target.
  • Leave one running past the limit: it ends by itself.
  • With the impersonation session's cookie, call the provider's own account API directly (Better Auth's /api/auth/update-user, for example): refused where the provider lets you lock it, and stated in the dialog where it does not.