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 });| Session | Replaced. session.user is now the target; session.session.impersonatedBy holds the admin's user id. |
| Where it lives | Redis, via Better Auth's secondaryStorage. There is no sessions table - do not look for one in the schema. |
| Expiry | 1 hour. admin() is registered with no options, so Better Auth's default applies. Pass impersonationSessionDuration to change it. |
| Permissions | Exactly 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 session | Recorded as both: the actor stays the impersonated user (whose data changed), and impersonated_by carries the admin's id. |
| Start and stop | auth.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 stop | The 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
| Concern | Where |
|---|---|
| Start / stop from the admin UI | lib/hooks/use-admin-user-actions.ts |
| Server-side self-target guard | packages/auth/src/impersonation-self-guard.ts |
| The banner and its exit path | components/admin/impersonation-banner.tsx |
| Where the banner mounts | app/[locale]/layout.tsx (app root) |
| Plugin registration and options | packages/auth/src/config.ts |
Audit Logs
Record admin, billing, and security events to the audit_logs table with @repo/audit, and read them from the admin and org audit views.
Content sections
Buyer-defined URL prefixes typed by config.content, each with a listing page and one of four templates, all rendered by a single shared route family.