# impersonating-users-safely

> 

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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
