# Impersonation

> Let support staff view the app as a user, read-only by default, with every session recorded and audited.

Source: https://bettersupabase.com/docs/auth/impersonation

Support staff sometimes need to see what a user sees, or fix their data the
way the app would. A support session does this for the whole app: the admin
picks a user, and every page, action and route renders as that user, under
their RLS policies, until the session ends or expires. The admin stays signed
in as themselves the whole time.

## Support sessions [#support-sessions]

Install the `support-sessions` SQL module, then pass a store to `createServer`:

```bash
npx better-supabase sql add support-sessions
```

```ts title="src/lib/better-supabase.ts"
import {
  createServer,
  sqlSupportStore,
  supportSessions,
} from "better-supabase/server";

export const bs = createServer(betterSupabase, {
  env,
  postgres,
  support: supportSessions({
    store: sqlSupportStore(postgres),
    policy: { ttl: 30 * 60, requireReason: true },
  }),
});
```

`supportSessions` lives in its own function so apps without support mode
don't bundle it. `createNext` takes the same option, and `better-supabase/next`
exports `supportSessions` too.

The module adds a `support_sessions` table and four functions:
`start_support_session`, `end_support_session`, `active_support_session` and
`list_support_sessions`. Starting a session checks
`is_platform('support.start')` in the database with the admin's own claims, so
only admins your [access model](/docs/blocks/access) grants that permission
can start one. Grant it with a platform permission claim (`"platform_permissions": ["support.*"]`)
or a platform role in the catalog model.

In Next.js, start and stop the session from server actions:

```ts title="app/admin/users/actions.ts"
"use server";
import { bs } from "@/lib/supabase/server";

export async function viewAs(userId: string, reason: string) {
  return bs.startSupport({ targetUserId: userId, reason });
}

export async function stopViewing() {
  return bs.stopSupport();
}
```

`startSupport` records the session, sets an HTTP-only `bs-support` cookie that
holds the session id, and returns an `ActionResult`. From the next request on,
`bs.session()`, `bs.context()`, actions, routes and `bs.cached()` all run as
the target. Ending the session (or its expiry) brings the admin back to their
own view. Outside Next.js, `bs.support.start(auth, request)` returns the
session and the `Set-Cookie` header to send.

### What the admin can do [#what-the-admin-can-do]

| Policy option   | Default    | Effect                                                                                    |
| --------------- | ---------- | ----------------------------------------------------------------------------------------- |
| `ttl`           | `1800`     | Session length in seconds when the request names none                                     |
| `maxTtl`        | `14400`    | Longest session allowed (`SUPPORT_TTL` above it); the SQL module checks `maxTtl` too      |
| `requireReason` | `true`     | Refuses a start without a reason (`SUPPORT_REASON_REQUIRED`)                              |
| `readOnly`      | `"always"` | Every session is read-only; `"default"` lets the request pass `readOnly: false` for fixes |

A read-only session runs each transaction with `begin read only`, so any write
fails with Postgres error `25006`. In a support session, `ctx.supabase` and
`ctx.db.$client` throw: the Data API would only see the admin's own token, so
everything runs over [Postgres](/docs/auth/postgres) with the target's claims.

Starting a second session ends the admin's first one. Admins can't start a
session for themselves (`SUPPORT_SELF`) or from inside another one
(`SUPPORT_NESTED`).

### Deciding who may start one [#deciding-who-may-start-one]

`authorize` runs before the store, with the admin's verified auth, the target
and the reason. Returning `false` (or throwing) refuses the start with
`SUPPORT_FORBIDDEN`:

```ts
support: supportSessions({
  store: sqlSupportStore(postgres),
  authorize: ({ admin, targetUserId }) =>
    admin.claims.app_metadata?.role === "support" && targetUserId !== OWNER_ID,
}),
```

In SQL, the `before_support_start(admin, target, reason, metadata)` hook can
refuse a start too, for example when no open ticket names the user. See
[support sessions](/docs/blocks/sql#support-sessions) for the module's names,
hooks and options.

### The target's claims [#the-targets-claims]

The target gets the claims your [custom access token hook](/docs/auth#typed-claims)
would give them, so tenant and role claims match a real session. Set
`blocks["support-sessions"].options.claimsHook` to the hook function
(`"public.custom_access_token_hook"`) and `support_target_claims` calls it with
a synthetic event. Without a hook, the target gets the claims Auth puts in
every token (`email`, `role`, `app_metadata`, `user_metadata`, `aal1`). Pass
`claims` in the options to build them in TypeScript instead.

### Showing a banner [#showing-a-banner]

`useSupportSession()` from `better-supabase/react` returns the session while
the admin views the app as someone else, and `undefined` otherwise:

```tsx title="components/support-banner.tsx"
"use client";
import { useSupportSession } from "better-supabase/react";
import { stopViewing } from "@/app/admin/users/actions";

export function SupportBanner() {
  const support = useSupportSession();
  if (!support) return null;
  return (
    <div role="status">
      Viewing as {support.targetUserId}
      {support.readOnly ? " (read-only)" : ""}
      <button onClick={() => stopViewing()}>Stop</button>
    </div>
  );
}
```

On the server, `supportOf(session)` returns the same value.

### Events and the audit log [#events-and-the-audit-log]

`sb.on` receives `support.started`, `support.ended` (with `endedBy`: `admin`,
`expired` or `revoked`) and `support.denied` (with the denial code).
The SQL module writes `support.started` and `support.ended` to the
[audit log](/docs/blocks/sql#audit-log), and every row the admin changes in a
writable session records `impersonated_by` and `support_session_id`, so you
can list everything one session did.

`bs.support.list(auth, { active: true })` lists running sessions for an
admin page, and `bs.support.revoke(auth, id)` ends a session. Both run as the
caller: the SQL store lists sessions for staff with `support.read`, and ends
the caller's own session or, with `support.revoke`, anyone's.

The SQL module refuses a target who holds platform permissions (the platform
claim in `app_metadata`, or a platform role), because the admin would gain
that staff member's reach (`allowPlatformTargets` turns this off). Sessions
are read-only unless `blocks["support-sessions"].options.allowWrites` is true and
the policy is `readOnly: "default"`. A token that carries an `act` claim never
has platform permissions, so a support session can't start another one or act
as staff. Each request checks again that the admin still holds
`support.start`, and an admin has one active session at a time.

### Bringing your own store [#bringing-your-own-store]

`SupportSessionStore` (`apiVersion: 1`) is the interface behind
`sqlSupportStore`. Implement `start`, `get`, `end` and `list` to keep sessions
somewhere else, and run `testSupportSessionStore` from `better-supabase/testing`
against it (see [conformance](/docs/extending/conformance)).

## Acting as a user in code [#acting-as-a-user-in-code]

`actingAs` runs repositories as a user for one piece of server code, without a
session or a cookie. Use it in jobs and scripts:

```ts title="app/admin/actions.ts"
const db = bs.actingAs(
  userId,
  { tenant_id: organizationId },
  { actor: admin.id, reason: "Support ticket 4211" },
);
await db.invoices.update(id, { status: "void" });
```

The third argument adds an [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693#section-4.1)
`act` claim to the session: `{ kind: "impersonation", sub: admin.id, reason }`. Policies still see
`auth.uid() = userId`. Check who acts first: `actingAs` trusts its caller, so
only call it after your own admin check.

Jobs run as the user who enqueued them with `bs.forContext(job.context)`,
which keeps the impersonating admin when the job was enqueued during
impersonation. See
[Jobs, webhooks and agents without a session](/docs/guides/without-a-session).

## What gets recorded [#what-gets-recorded]

| Where              | What                                                                                                                             |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `audit` SQL module | `audit_events.impersonated_by`, `impersonation_reason` and `support_session_id` on every row the admin changes                   |
| `actor` SQL module | `track_actor(table, impersonated_by => 'impersonated_by')` stamps the admin on each write; the user's own writes cannot clear it |
| Repository context | `context.actor.impersonator`, for plugins                                                                                        |
| In SQL             | `auth.jwt() -> 'act' ->> 'sub'` and `auth.jwt() -> 'act' ->> 'session_id'`, for your own triggers and policies                   |

A policy can also refuse impersonated writes where they make no sense:

```sql
create policy "no impersonated payouts" on public.payouts
  as restrictive for insert
  with check (auth.jwt() -> 'act' is null);
```

## Why only over Postgres [#why-only-over-postgres]

Support sessions and `actingAs` need [`postgres`](/docs/auth/postgres) in
`createServer`. They set the claims for the transaction itself, the way
PostgREST does after checking a token. The Data API has no equivalent:

* PostgREST only trusts tokens signed by the project's JWT signing keys. With
  asymmetric keys, only the Auth server holds the private key, so the app
  can't mint a token with an `act` claim.
* Auth has no impersonation API. A token you could mint (with a legacy
  shared secret) would be a full session for the user, with no expiry control
  and no record in Auth's audit log.

So over the Data API, the claims are whatever Auth issued. Run impersonated
work on the server over Postgres, and keep the admin signed in as themselves.

## Reading the act claim [#reading-the-act-claim]

`act` is the [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693#section-4.1)
actor claim, which OAuth clients and agent chains carry too. Its `kind`
says which of them a token is, and `session.actor` and `session.impersonator`
always agree:

| `act`                                                                             | `session.actor.kind`                       | `session.impersonator` | `session.delegation`            |
| --------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------- | ------------------------------- |
| `{ kind: "support", sub, reason, session_id, read_only }`, from a support session | `support`, with `sessionId` and `readOnly` | set                    | never                           |
| `{ kind: "impersonation", sub, reason }`, from `actingAs`                         | `impersonation`                            | set                    | never                           |
| no `kind`, from an OAuth client or an agent chain                                 | `oauth-client`, with the `chain`           | not set                | the `scope` claim and the chain |
| none, with a `client_id` (Supabase's OAuth server)                                | `oauth-client`                             | not set                | the `scope` claim               |

Only an `oauth-client` actor is limited to the scopes the user delegated: the
`scopes` guard option checks a support or impersonated session as the user's
own, and a read-only support session still blocks writes. A support token
from 0.5.0, with `session_id` but no `kind`, counts as a support session
until 0.6. An `act` claim that is not a chain of objects each with a `sub`,
that has another `kind`, or a support level without `session_id` makes the
session `{ kind: 'invalid', reason: 'actor' }`.

`impersonatorOf(claims)` from `better-supabase/server` reads the same claim
anywhere else, and `supabaseClaimFixtures` from `better-supabase/testing`
holds a token of each kind, for tests:

```ts title="tests/support-banner.test.ts"
import { supabaseClaimFixtures } from "better-supabase/testing";

const { claims, expect: read } = supabaseClaimFixtures.supportSessionReadOnly;
// read.actor: { kind: "support", id, sessionId, readOnly: true, reason }
```