# Admin impersonation on Supabase Auth, bound to one session

> Supabase Auth has no "view as user". Build it from a server-side magic link plus an app_metadata marker tied to the new session id, so only that session is flagged and nobody can forge it.

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:

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

```json
{ "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 change
  `user_metadata` from 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_id`
  claim. 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:

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

```sql
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/ssr` cookies 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_after` stops 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 `impersonatedByFor` with 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_after` for the new session in `auth.sessions`: an hour after
  `created_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
  changes `user_metadata`, not `app_metadata`, and the banner stays.

---

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
