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
Why Impersonation?
I used to think user impersonation was just a nice feature. Then I heard stories of support teams struggle with endless screen shares where they trying to debug issues that couldn't be reproduced. Meanwhile, customers grew frustrated explaining the same problem over and over.
Support and success teams need to:
- Reproduce issues as the customer sees them.
- Perform allowed actions on behalf of a user to unblock them.
- Verify data in context without endless back-and-forth
But the thing is that impersonation isn't just logging in as someone else. Done wrong, it's a compliance nightmare and a trust violation. When done the right way, it's a superpower for the support team.
So what is the key point here? Impersonation must be deliberate, time-boxed, fully audited, and clearly visible to everyone involved.
Actor vs. Effective User
Before we dive into code, let's create a mind map that makes everything else make sense.
Every impersonation session has two identities:
- Actor: The admin who initiated the impersonation
- Effective User: The user being impersonated
Let's try to understand this through a hypothetical scenario. When your support manager Jane helps customer Bob, Jane is still Jane (the actor), but she's operating with Bob's permissions and context (the effective user).
This separation is crucial because every action in your system should record both identities. Your audit logs need to show "Jane did X while acting as Bob" - not just "Bob did X."
Here's how we implement this separation:
src/lib/session.ts
import { getSession } from '@/lib/auth'
import { getImpersonationFromCookies } from '@/lib/impersonation'
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
export async function getMe() {
const session = await getSession()
if (!session?.user?.id) return null
const imp = await getImpersonationFromCookies()
if (imp.active && imp.targetUserId) {
const target = await prisma.user.findUnique({
where: { id: imp.targetUserId }
})
return target
}
return session.user
}Data Model: Impersonation + Audit
The database design here isn't just about functionality, it's designed to pass audits. Introducing compliance into an existing system can be painful so it is better to build it right from the start.
Two models power the feature: Impersonation stores the session details, and AuditLog records every action with dual identity tracking.
prisma/schema.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])
}Few important things to notice are below:
The reason field is required. This isn't optional. Auditors want to know why every impersonation happened. "Debugging payment issue" or "Helping user reset preferences" tells a story that "because I can" doesn't.
Scope controls are built-in. The scope array lets you implement fine-grained permissions. Most teams start with ["read"] for investigations and ["read", "support"] for actions.
Everything has timestamps and relations. createdAt, expiresAt, endedAt, plus who ended it and why. This gives you a complete timeline that auditors love to see.
Tokens, Cookies, and TTL
Here's where many implementations go wrong and that is storage of the impersonation token directly in the database. You should never do that.
Instead, we generate a random token, hash it with SHA-256, and store only the hash. The cookie contains impersonationId:token, making it self-contained while keeping the actual secret out of the database.
src/lib/impersonation.ts
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import crypto from 'crypto'
const COOKIE_NAME = 'impersonation_token'
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
export async function startImpersonation(params: {
actorId: string
targetUserId: string
reason: string
scope: string[]
durationMinutes: number
}) {
const token = crypto.randomBytes(32).toString('hex')
const tokenHash = hashToken(token)
const expiresAt = new Date(Date.now() + params.durationMinutes * 60 * 1000)
const imp = await prisma.impersonation.create({
data: {
tokenHash,
actorId: params.actorId,
targetUserId: params.targetUserId,
reason: params.reason,
scope: params.scope,
expiresAt,
},
})
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, `${imp.id}:${token}`, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt,
})
return imp
}
export async function getImpersonationFromCookies() {
const cookieStore = await cookies()
const raw = cookieStore.get(COOKIE_NAME)?.value
if (!raw) return { active: false }
const [id, token] = raw.split(':')
if (!id || !token) return { active: false }
const tokenHash = hashToken(token)
const imp = await prisma.impersonation.findUnique({
where: { id, tokenHash },
include: { actor: true, target: true },
})
if (!imp || imp.endedAt || imp.expiresAt < new Date()) {
return { active: false }
}
return {
active: true,
impersonationId: imp.id,
actorId: imp.actorId,
targetUserId: imp.targetUserId,
scope: imp.scope,
reason: imp.reason,
expiresAt: imp.expiresAt,
actor: imp.actor,
target: imp.target,
}
}Why this approach works:
The first and foremost is Token splitting. Even if someone gets the database, they can't reconstruct valid tokens.
Second is HTTP-only cookies. No JavaScript access means no XSS token theft.
The third is Short TTL. 30 minutes default forces intentional renewal.
And the last one is Automatic cleanup. Expired tokens fail validation gracefully.
The getImpersonationFromCookies() function does all the heavy lifting, cookie parsing, hash verification, expiry checking, and returning a clean interface for the rest of your app.
Starting and Stopping
Only admins should start impersonation sessions, and even then, with constraints. The API enforces several business rules that I think are essential:
src/app/api/impersonation/start/route.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')
}The checks/guard rails are here for a reason:
No admin impersonation: Admins impersonating other admins is almost never legitimate and will creates audit nightmares
No self-impersonation: This sounds silly but silly things are bound to happen when an app is in production.
Duration limits: Some hours set to maximum because longer sessions usually indicate process problems.
Mandatory audit trail: Every start gets logged with context before the redirect.
Stopping is simpler but equally important. The session needs to end cleanly with proper logging, whether it's manual or automatic expiry.
src/app/api/impersonation/stop/route.ts
export async function POST(req: Request) {
const me = await getMe()
if (!me) return unauthorized()
const imp = await getImpersonationFromCookies()
if (!imp.active) return badRequest('No active impersonation')
await stopImpersonation('Manual stop', me.id)
const { ip, userAgent } = getRequestMeta(req)
await logAudit({
actorId: imp.actorId,
effectiveUserId: imp.targetUserId,
impersonationId: imp.impersonationId!,
action: 'impersonation.stop',
metadata: { reason: 'Manual stop' },
ip,
userAgent,
})
return redirect('/')
}The supporting function handles the cookie cleanup and database update:
src/lib/impersonation.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
This is probably the most important UX element. Making impersonation impossible to miss. Impersonation indicator should never be subtle or buried in a menu.
The banner should be prominent, persistent, and informative:
src/components/ImpersonationBanner.tsx
import { getImpersonationFromCookies } from '@/lib/impersonation'
export async function ImpersonationBanner() {
const imp = await getImpersonationFromCookies()
if (!imp.active) return null
const remaining = Math.round(
(imp.expiresAt.getTime() - Date.now()) / 1000 / 60
)
return (
<div className="bg-amber-100 border-b-2 border-amber-500 px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-semibold text-amber-900">
⚠️ Impersonating {imp.target.name || imp.target.email}
</span>
<span className="text-sm text-amber-700">
Started by {imp.actor.name || imp.actor.email}
</span>
<span className="text-sm text-amber-600">
{remaining}m remaining
</span>
<span className="text-xs text-amber-600">
Scope: {imp.scope.join(', ')}
</span>
</div>
<form action="/api/impersonation/stop" method="POST">
<button
type="submit"
className="px-4 py-2 bg-amber-900 text-white rounded hover:bg-amber-800"
>
Stop Impersonation
</button>
</form>
</div>
</div>
)
}Why amber/yellow? It's the universal color for "caution" - not error-red but definitely "pay attention." The banner shows:
- Who's being impersonated - removes any ambiguity
- Who started it - accountability is visible
- Time remaining - urgency indicator
- Current scope - what actions are permitted
- One-click stop - no hunting through menus
For even better observability, we add a response header that monitoring tools can detect:
middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
// ... other middleware logic ...
res = NextResponse.next()
const raw = req.cookies.get('impersonation_token')?.value
if (raw) {
res.headers.set('x-impersonating', 'true')
}
return res
}This header lets your monitoring tools (DataDog, New Relic, etc.) flag impersonation sessions in dashboards and alerts.
Auditing and Observability
Every impersonation action needs to be logged with both actor and effective user context. This isn't just good practice but rather a legal requirement.
src/lib/audit.ts
import { prisma } from '@/lib/prisma'
export async function logAudit(params: {
actorId: string
effectiveUserId: string
impersonationId?: string
action: string
metadata?: any
ip?: string
userAgent?: string
}) {
return prisma.auditLog.create({
data: {
actorId: params.actorId,
effectiveUserId: params.effectiveUserId,
impersonationId: params.impersonationId,
action: params.action,
metadata: params.metadata ?? {},
ip: params.ip,
userAgent: params.userAgent,
},
})
}For compliance, you'll want an admin view to review recent logs:
src/app/admin/audit/page.tsx
const logs = await prisma.auditLog.findMany({
take: 50,
orderBy: { createdAt: 'desc' },
include: {
actor: { select: { name: true, email: true } },
effectiveUser: { select: { name: true, email: true } },
}
})Auditors typically want to see:
- Who performed each action (both actor and effective user)
- When it happened (with timezone)
- What scope was active
- IP address and user agent for forensics
- Structured metadata for the specific action
Guarding Sensitive Actions and Scope
Many teams start with "read-only impersonation" as a safety measure. That's smart, but you'll quickly need more nuanced controls. The scope array in the impersonation record enables this.
Here's how to guard sensitive actions:
Example: route handler guard
export async function POST(req: Request) {
const { actorId, effectiveUserId, impersonationId } =
await getActorAndEffectiveUser()
if (impersonationId) {
const imp = await prisma.impersonation.findUnique({
where: { id: impersonationId }
})
if (!imp?.scope.includes('write')) {
return forbidden('Write scope required')
}
}
// ... proceed with sensitive operation ...
}If you're multi-tenant, include a tenant identifier in the impersonation scope, e.g. scope: ["tenant:acme", "read"], and enforce it in queries.
Authentication Integration: BetterAuth Setup
The authentication layer needs to work with impersonation. Here's how the BetterAuth integration handles session enrichment and role checking:
src/lib/auth.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
}
}The key insight here is that impersonation tokens are completely separate from authentication sessions. Your regular auth session (the admin's identity) stays intact while the impersonation cookie provides the effective user context.
This separation is crucial for security because if someone compromises an impersonation token, they can't extend it or create new ones without the underlying admin session.
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-impersonatingheader. - 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.
Conclusion
User impersonation seems straightforward until you dig into the details.
This implementation gives you:
- Security through token hashing and session separation
- Compliance through comprehensive auditing and access controls
- Usability through clear indicators and time limits
- Maintainability through clean abstractions and consistent patterns
The patterns here can scale from small teams to enterprise environments. You can use variations of this system to pass SOC 2 audits, support millions of users, and keep support teams happy.
The foundation is solid enough to build on, but simple enough to understand and modify.
Want to see it in action? Check out the full repository: https://github.com/riazosama/nextjs-impersonation-betterauth-prisma (code is not production ready but just to give an idea)
