Practical User Impersonation

practical_user_impersonation.png

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?

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 screen shares

Impersonation must be deliberate, time‑boxed, fully audited, and clearly visible to everyone involved.

Actor vs. Effective User

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.


src/lib/session.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 }
}


```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.


CONSULT WITH EXPERTS REGARDING YOUR PROJECT

We have a team who’s ready to make your dreams into a reality. Let us know what you have in mind.

Read More

INTERESTED IN WORKING WITH US?

Let’s Talk And Get Started