GenerateSaaS

Impersonation

Sign in as another user to reproduce their problem - how a session starts and ends, what the self-target guard covers, and what the audit log attributes.

An admin can act as any user to reproduce a bug they cannot see themselves. It is Better Auth's admin() plugin, gated on the platform admin role, and it is on for every project - there is no config flag.

Actions taken during an impersonated session are attributed to the impersonated user, not to the admin driving them. Read what the audit log records before relying on this for support.

Starting a session

From Admin → Users, use the row menu or the user detail sheet:

await authClient.admin.impersonateUser({ userId });
SessionReplaced. session.user is now the target; session.session.impersonatedBy holds the admin's user id.
Where it livesRedis, via Better Auth's secondaryStorage. There is no sessions table - do not look for one in the schema.
Expiry1 hour. admin() is registered with no options, so Better Auth's default applies. Pass impersonationSessionDuration to change it.
PermissionsExactly the target's. The admin keeps nothing - if the target is not an admin, /admin is closed for the duration.

Ending a session

Three exits, because an admin who cannot tell they are still impersonating is the practical failure mode:

  • The banner - a fixed orange banner naming the impersonated user, with a stop button. It calls stopImpersonating() then hard-navigates, so the restored admin cookie is sent on the next request and the impersonated identity cannot flash on screen first.
  • Signing out ends the impersonated session like any other.
  • Expiry after an hour, with no warning.

The banner mounts at the locale root layout, not in the dashboard layout, so it survives navigation to a marketing or public page. It reads the session client-side and renders nothing otherwise, so a public page pays no session fetch for it.

Self-targeting is refused

refuseSelfImpersonation() (packages/auth/src/impersonation-self-guard.ts) runs from a Better Auth before hook on /admin/impersonate-user and throws a 400 when the target is the caller. The admin UI also renders the menu item disabled and returns early with a toast, so a direct API call is refused exactly like a click.

A self-impersonation session would corrupt the audit trail rather than merely be pointless: it carries impersonatedBy equal to its own user, so every entry written during it claims somebody acted as somebody else, and stopping restores a session that was never replaced.

What the audit log records

An impersonated session is recorded end to end - the boundaries and everything between them.

Actions during the sessionRecorded as both: the actor stays the impersonated user (whose data changed), and impersonated_by carries the admin's id.
Start and stopauth.impersonation_started / auth.impersonation_stopped, with the admin as the actor and the impersonated user as the entity. Both carry impersonated_by and share an episodeId in metadata, so repeat episodes between the same pair line up.
What writes a stopThe admin clicking stop, signing out, or revoking that session - revokeSessions(), or revokeSession() naming its token. Revoking a different device of the same user, or revokeOtherSessions(), writes nothing.

createAuditContext(req, session?, clientIp?) fills impersonated_by from the session it is handed, with nothing passed at the call site. Query the review question directly:

SELECT * FROM audit_logs WHERE impersonated_by IS NOT NULL;

A start with no matching stop does not mean the episode is open. Four things produce one: it is still open; it expired (the Redis session's one-hour TTL lapses silently); it was ended from outside (banning, deleting, or revoking the user's sessions from the admin API - that request was not the impersonated one, so nothing can attribute the end); or the write failed (audit() swallows its own errors so it can never break a sign-out). Compare the start's timestamp against the hour to separate the first two. See Audit logs.

Extending it

ConcernWhere
Start / stop from the admin UIlib/hooks/use-admin-user-actions.ts
Server-side self-target guardpackages/auth/src/impersonation-self-guard.ts
The banner and its exit pathcomponents/admin/impersonation-banner.tsx
Where the banner mountsapp/[locale]/layout.tsx (app root)
Plugin registration and optionspackages/auth/src/config.ts

On this page