Impersonation
Let support staff view the app as a user, read-only by default, with every session recorded and audited.
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
Install the support-sessions SQL module, then pass a store to createServer:
npx better-supabase sql add support-sessionsimport {
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 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:
"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
| 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 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
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:
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 for the module's names,
hooks and options.
The target's claims
The target gets the claims your custom access token hook
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
useSupportSession() from better-supabase/react returns the session while
the admin views the app as someone else, and undefined otherwise:
"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
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, 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
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).
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:
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
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.
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:
create policy "no impersonated payouts" on public.payouts
as restrictive for insert
with check (auth.jwt() -> 'act' is null);Why only over Postgres
Support sessions and actingAs need 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
actclaim. - 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
act is the RFC 8693
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:
import { supabaseClaimFixtures } from "better-supabase/testing";
const { claims, expect: read } = supabaseClaimFixtures.supportSessionReadOnly;
// read.actor: { kind: "support", id, sessionId, readOnly: true, reason }Last updated on