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?

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 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(): Promise<SessionUser | null> {
  const session = await getSession()
  return (session?.user as any) || null
}

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 { 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 }
}

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 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('/')
}

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
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>
  )
}

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) {
.....
.....
  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
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: 
  })
}

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' } })

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

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

--------

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)


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