When you build admin tools for a multi-user app, “Can support sign in as a customer to help them?” arrives sooner than you think. That’s user impersonation. Get it wrong, and you have privacy incidents. Get it right, and you unlock first‑class support without violating trust.
This post walks through a complete, compliant implementation using Next.js (App Router), Tailwind CSS, BetterAuth, Prisma, and Postgres (supporting repo is attached). Code snippets are from that repo so you can follow along: https://github.com/riazosama/nextjs-impersonation-betterauth-prisma
Support and success teams need to:
Impersonation must be deliberate, time‑boxed, fully audited, and clearly visible to everyone involved.
The core idea is to separate the authenticated admin (the actor) from the user they are acting as (the effective user). Every action should record both.
import { getSession } from '@/lib/auth'
import { getImpersonationFromCookies } from '@/lib/impersonation'
export async function export async function getActorAndEffectiveUser() {
const me = await getMe()
const imp = await getImpersonationFromCookies()
if (imp.active) {
return {
actorId: imp.actorId!,
effectiveUserId: imp.targetUserId!,
impersonationId: imp.impersonationId!,
user: me,
}
}
return { actorId: me?.id, effectiveUserId: me?.id, impersonationId: undefined, user: me }
}
```ts
import { getSession } from '@/lib/auth'
import { getImpersonationFromCookies } from '@/lib/impersonation'
export async function export async function getActorAndEffectiveUser() {
const me = await getMe()
const imp = await getImpersonationFromCookies()
if (imp.active) {
return {
actorId: imp.actorId!,
effectiveUserId: imp.targetUserId!,
impersonationId: imp.impersonationId!,
user: me,
}
}
return { actorId: me?.id, effectiveUserId: me?.id, impersonationId: undefined, user: me }
}
```
##### src/lib/me.ts
```ts
export async function getMe(): Promise<SessionUser | null> {
const session = await getSession()
return (session?.user as any) || null
}
```
## Data Model: Impersonation + Audit
Two models power the feature: `Impersonation` stores who, why, scope, and TTL and `AuditLog` records every action with both actor and effective IDs.
##### prisma/schema.prisma
```prisma
model Impersonation {
id String @id @default(cuid())
tokenHash String @unique
actorId String
targetUserId String
reason String
scope String[] // e.g., ["read", "support"]
createdAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedById String?
endedReason String?
actor User @relation("ImpersonationActor", fields: [actorId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
endedBy User? @relation("ImpersonationsEndedBy", fields: [endedById], references: [id])
audits AuditLog[]
}
model AuditLog {
id String @id @default(cuid())
actorId String
effectiveUserId String
impersonationId String?
action String
metadata Json?
ip String?
userAgent String?
createdAt DateTime @default(now())
actor User @relation("AuditActor", fields: [actorId], references: [id], onDelete: Cascade)
effectiveUser User @relation("AuditEffective", fields: [effectiveUserId], references: [id], onDelete: Cascade)
impersonation Impersonation? @relation(fields: [impersonationId], references: [id])
@@index([createdAt])
@@index([impersonationId])
}
```
## Tokens, Cookies, and TTL
We generate a random token, store its SHA‑256 hash in the DB, and set an HTTP‑only cookie that pairs `impersonationId:token`. This keeps the cookie self‑contained while avoiding storing the raw token server‑side.
##### src/lib/impersonation.ts
```ts
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import { randomToken, sha256 } from '@/lib/crypto'
const COOKIE_NAME = 'impersonation_token'
export async function startImpersonation(params: {
actorId: string
targetUserId: string
reason: string
scope: string[]
durationMinutes?: number
}) {
const { actorId, targetUserId, reason, scope, durationMinutes = 30 } = params
const token = randomToken(32)
const tokenHash = sha256(token)
const expiresAt = new Date(Date.now() + durationMinutes * 60 * 1000)
const imp = await prisma.impersonation.create({ data: { tokenHash, actorId, targetUserId, reason, scope, expiresAt } })
const cookieStore = await cookies()
cookieStore.set({
name: COOKIE_NAME,
value: `${imp.id}:${token}`,
httpOnly: true,
secure: true,
path: '/',
sameSite: 'lax',
expires: expiresAt,
})
return imp
}
export async function getImpersonationFromCookies() {
const raw = (await cookies()).get(COOKIE_NAME)?.value
if (!raw) return { active: false }
const [id, token] = raw.split(':')
const tokenHash = sha256(token)
const imp = await prisma.impersonation.findUnique({ where: { id } })
if (!imp || imp.tokenHash !== tokenHash || imp.endedAt || imp.expiresAt.getTime() < Date.now()) return { active: false }
return { active: true, impersonationId: imp.id, actorId: imp.actorId, targetUserId: imp.targetUserId, scope: imp.scope, expiresAt: imp.expiresAt }
}
```
## Starting and Stopping
Only admins can start impersonation. We capture a reason, optional scope, and duration. We also prevent self‑impersonation and impersonating other admins.
##### src/app/api/impersonation/start/route.ts
```ts
export async function POST(req: Request) {
const me = await getMe()
if (!me) return unauthorized()
if (me.role !== 'ADMIN') return forbidden()
const form = await req.formData()
const targetUserId = String(form.get('targetUserId') || '')
const reason = String(form.get('reason') || '')
const scopeRaw = String(form.get('scope') || '')
const durationRaw = String(form.get('duration') || '')
if (!targetUserId || !reason) return badRequest('Missing fields')
const target = await prisma.user.findUnique({ where: { id: targetUserId } })
if (!target) return notFound('Target not found')
if (target.role === 'ADMIN') return badRequest('Cannot impersonate admins')
if (me.id === target.id) return badRequest('Cannot impersonate yourself')
const scope = scopeRaw ? scopeRaw.split(',').map((s) => s.trim()).filter(Boolean) : ['support']
const durationMinutes = durationRaw ? Math.max(1, Math.min(240, parseInt(durationRaw))) : 30
const imp = await startImpersonation({ actorId: me.id, targetUserId: target.id, reason, scope, durationMinutes })
const { ip, userAgent } = getRequestMeta(req)
await logAudit({
actorId: me.id,
effectiveUserId: target.id,
impersonationId: imp.id,
action: 'impersonation.start',
metadata: { reason, scope, durationMinutes },
ip,
userAgent,
})
return redirect('/profile')
}
```
Stopping clears the cookie, records who ended it, and logs the event.
##### src/app/api/impersonation/stop/route.ts
```ts
export async function POST(req: Request) {
const me = await getMe()
if (!me) return unauthorized()
const imp = await stopImpersonation('manual stop', me.id)
if (imp) {
const { ip, userAgent } = getRequestMeta(req)
await logAudit({
actorId: imp.actorId,
effectiveUserId: imp.targetUserId,
impersonationId: imp.id,
action: 'impersonation.stop',
ip,
userAgent,
})
}
return redirect('/')
}
```
Supporting lib function:
##### src/lib/impersonation.ts
```ts
export async function stopImpersonation(reason?: string, endedById?: string) {
const cookieStore = await cookies()
const raw = cookieStore.get(COOKIE_NAME)?.value
if (!raw) return null
const [id] = raw.split(':')
cookieStore.delete(COOKIE_NAME)
const imp = await prisma.impersonation.update({
where: { id },
data: { endedAt: new Date(), endedReason: reason, endedById: endedById ?? undefined },
})
return imp
}
```
## Banner and UX Safeguards
Impersonation must be unmistakable. A persistent banner shows who started it, who you’re acting as, the scope, and time remaining — plus a one‑click stop.
##### src/components/ImpersonationBanner.tsx
```tsx
export async function ImpersonationBanner() {
const ctx = await getImpersonationFromCookies()
if (!ctx.active) return null
const remaining = ctx.expiresAt ? Math.max(0, Math.floor((ctx.expiresAt.getTime() - Date.now()) / 60000)) : null
return (
<div className="w-full bg-amber-100 text-amber-900">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-6 py-2 text-sm">
<div>
<strong>Impersonation active</strong>: Acting as user {ctx.targetUserId} • Initiated by {ctx.actorId}
{remaining !== null && <span> • Expires in {remaining}m</span>} • Scope: {ctx.scope?.join(', ') || 'none'}
</div>
<form action="/api/impersonation/stop" method="post">
<button className="rounded bg-amber-200 px-3 py-1 hover:bg-amber-300" type="submit">Stop</button>
</form>
</div>
</div>
)
}
```
We also add an `x-impersonating` response header in middleware so observability tools can flag sessions:
##### middleware.ts
```ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const url = req.nextUrl
const pathname = url.pathname
// Allowlist: static assets and setup/auth APIs/pages to avoid loops
const allow =
pathname.startsWith('/_next') ||
pathname === '/favicon.ico' ||
pathname.startsWith('/api/setup') ||
pathname.startsWith('/api/auth') ||
pathname.startsWith('/setup')
let res: NextResponse | null = null
try {
// Only check initialization when not allowlisted
if (!allow) {
const statusUrl = new URL('/api/setup/status', req.url)
const r = await fetch(statusUrl.toString(), { headers: { 'x-from-middleware': '1' }, cache: 'no-store' })
if (r.ok) {
const data = (await r.json()) as { initialized?: boolean }
if (!data?.initialized) {
const redirectUrl = new URL('/setup', req.url)
return NextResponse.redirect(redirectUrl)
}
}
}
} catch {
// If status check fails, fall through and let request proceed
}
res = NextResponse.next()
const raw = req.cookies.get('impersonation_token')?.value
if (raw) {
res.headers.set('x-impersonating', 'true')
}
return res
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```
## Auditing and Observability
Every action stores both identities plus request context.
##### src/lib/audit.ts
```ts
type AuditParams = {
actorId: string
effectiveUserId: string
impersonationId?: string | null
action: string
metadata?: Record<string, unknown>
ip?: string | null
userAgent?: string | null
}
export async function logAudit(params: AuditParams) {
const { actorId, effectiveUserId, impersonationId, action, metadata, ip, userAgent } = params
await prisma.auditLog.create({
data: { actorId, effectiveUserId, impersonationId: impersonationId ?? null, action, metadata: metadata ? (metadata as any) : undefined, ip: ip ?? undefined, userAgent: userAgent ?? undefined },
})
}
```
There’s also an Admin view to review recent logs.
##### src/app/admin/audit/page.tsx
```tsx
const logs = await prisma.auditLog.findMany({ take: 50, orderBy: { createdAt: 'desc' } })
```
## Guarding Sensitive Actions and Scope
By default, many teams prefer read‑only impersonation. Use the stored `scope` to gate writes. Pattern:
##### example: route handler guard
```ts
import { getActorAndEffectiveUser } from '@/lib/session'
import { getImpersonationFromCookies } from '@/lib/impersonation'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const { actorId, effectiveUserId, impersonationId } = await getActorAndEffectiveUser()
const imp = await getImpersonationFromCookies()
const allowed = imp.active && imp.scope?.includes('write')
if (imp.active && !allowed) {
return NextResponse.json({ error: 'Writes disabled during impersonation' }, { status: 403 })
}
// …perform the write, and audit
// await logAudit({ actorId, effectiveUserId, impersonationId, action: 'ticket.update', metadata: { id } })
}
```
If you’re multi‑tenant, include a tenant identifier in the impersonation scope, e.g. `scope: ["tenant:acme", "read"]`, and enforce it in queries.
##### src/lib/auth.ts
```ts
import { betterAuth } from 'better-auth'
import { nextCookies, toNextJsHandler } from 'better-auth/next-js'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { prisma } from '@/lib/prisma'
import { cookies, headers as nextHeaders } from 'next/headers'
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: 'postgresql' }),
secret: process.env.BETTER_AUTH_SECRET,
plugins: [nextCookies()],
emailAndPassword: { enabled: true },
})
// Next.js route helpers (used by /api/auth catch-all)
export const handlers = toNextJsHandler(auth)
// Server-friendly session helper for RSC/route handlers
export async function getSession() {
const hdrs = new Headers()
const cookieStore = await cookies()
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${encodeURIComponent(c.value)}`)
.join('; ')
if (cookieHeader) hdrs.set('cookie', cookieHeader)
const h = await nextHeaders()
const ua = h.get('user-agent')
const ip = h.get('x-forwarded-for')
if (ua) hdrs.set('user-agent', ua)
if (ip) hdrs.set('x-forwarded-for', ip)
const res = await auth.api.getSession({ headers: hdrs, asResponse: true })
if (!res.ok) return null as any
try {
const data = (await res.json()) as any
// Ensure role is present from DB to support admin checks
if (data?.user?.id) {
try {
const dbUser = await prisma.user.findUnique({ where: { id: data.user.id }, select: { role: true } })
if (dbUser) data.user.role = dbUser.role
} catch {
// ignore enrichment failures; fall back to base session
}
}
return data
} catch {
return null as any
}
}
```
## Compliance Checklist
These are the controls auditors look for (SOC 2, ISO 27001, HIPAA where applicable):
- Allowed by role: Only privileged admins can start impersonation.
- Capture intent: Require a reason.
- Time‑box: Short TTL (e.g., 30–60m) with auto‑expiry; manual stop anytime.
- Dual identity: Store actor and effective user on every audit record.
- Immutable logs: Write‑once audit entries with timestamps, IP, and user agent.
- No privilege escalation: Prevent impersonating admins; forbid self‑impersonation.
- Visibility: Persistent banner and clear UI; add `x-impersonating` header.
- Least privilege: Default read‑only; explicit scopes for writes.
- Scope enforcement: Restrict tenant and action scope server‑side, not in the client.
- Session isolation: Keep impersonation token separate from auth session; httpOnly cookie.
- Termination trail: Record who ended the session and why.
That’s it — a minimal setup that’s practical for teams and friendly to auditors.
Let’s Talk And Get Started