SaaS User Impersonation: Admin Session Takeover, Audit Logging, and Safe Exit
Build a secure user impersonation system for SaaS admin dashboards. Covers admin session takeover without exposing passwords, impersonation audit trail, exit impersonation button, and permission guards to prevent privilege escalation.
Support teams need to see what users see. Impersonation β signing into a user's account as an admin without knowing their password β is standard in SaaS support tooling. The implementation challenge is doing this securely: impersonation must leave a clear audit trail, impersonated sessions must be clearly marked (so impersonated actions aren't attributed to the user), and exiting impersonation must restore the original admin session cleanly.
This guide covers the full implementation: the database schema, the server action that creates an impersonation session, the middleware that reads impersonation state, and the exit mechanism.
Database Schema
-- Audit log for all impersonation events
CREATE TABLE impersonation_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID NOT NULL REFERENCES users(id),
target_user_id UUID NOT NULL REFERENCES users(id),
reason TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
ended_by TEXT CHECK (ended_by IN ('admin', 'timeout', 'session_end'))
);
CREATE INDEX idx_impersonation_log_admin ON impersonation_log(admin_user_id, started_at DESC);
CREATE INDEX idx_impersonation_log_target ON impersonation_log(target_user_id, started_at DESC);
-- Add role column to users if not present
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('member', 'owner', 'admin'));
Session Token Design
Impersonation stores two pieces of data in the session cookie:
userIdβ the target user being impersonatedimpersonatedByβ the original admin's user ID + impersonation log entry ID
// types/session.ts
export interface SessionPayload {
userId: string;
workspaceId: string;
role: string;
// Set only during impersonation
impersonation?: {
adminUserId: string;
adminName: string;
logId: string; // References impersonation_log.id
startedAt: string;
};
}
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Server Action: Start Impersonation
// app/actions/impersonation.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { createSession } from "@/lib/session";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { z } from "zod";
const StartImpersonationSchema = z.object({
targetUserId: z.string().uuid(),
reason: z.string().min(10, "Reason must be at least 10 characters").max(500),
});
export async function startImpersonation(
input: z.infer<typeof StartImpersonationSchema>
): Promise<{ error?: string }> {
const session = await auth();
// Only super-admins can impersonate
if (!session?.user || session.user.role !== "admin") {
return { error: "Unauthorized" };
}
// Cannot impersonate yourself
if (session.user.id === input.targetUserId) {
return { error: "Cannot impersonate yourself" };
}
const parsed = StartImpersonationSchema.safeParse(input);
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
const targetUser = await prisma.user.findUnique({
where: { id: input.targetUserId },
select: { id: true, role: true, workspaceId: true, name: true },
});
if (!targetUser) return { error: "User not found" };
// Prevent impersonating another admin (privilege escalation guard)
if (targetUser.role === "admin") {
return { error: "Cannot impersonate another admin" };
}
const reqHeaders = await headers();
const ipAddress = reqHeaders.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
const userAgent = reqHeaders.get("user-agent") ?? "unknown";
// Create audit log entry
const logEntry = await prisma.impersonationLog.create({
data: {
adminUserId: session.user.id,
targetUserId: input.targetUserId,
reason: input.reason,
ipAddress,
userAgent,
},
});
// Create a new session token as the target user
// with impersonation metadata embedded
await createSession({
userId: targetUser.id,
workspaceId: targetUser.workspaceId,
role: targetUser.role,
impersonation: {
adminUserId: session.user.id,
adminName: session.user.name,
logId: logEntry.id,
startedAt: new Date().toISOString(),
},
});
redirect("/dashboard");
}
export async function stopImpersonation(): Promise<void> {
const session = await auth();
if (!session?.user?.impersonation) {
redirect("/dashboard");
}
const { adminUserId, logId } = session.user.impersonation;
// Mark impersonation as ended
await prisma.impersonationLog.update({
where: { id: logId },
data: { endedAt: new Date(), endedBy: "admin" },
});
// Restore the original admin session
const adminUser = await prisma.user.findUniqueOrThrow({
where: { id: adminUserId },
select: { id: true, role: true, workspaceId: true },
});
await createSession({
userId: adminUser.id,
workspaceId: adminUser.workspaceId,
role: adminUser.role,
// No impersonation field β clean admin session
});
redirect("/admin/users");
}
Impersonation Banner
Show a persistent banner whenever an admin is impersonating:
// components/impersonation-banner.tsx
import { auth } from "@/auth";
import { stopImpersonation } from "@/app/actions/impersonation";
export async function ImpersonationBanner() {
const session = await auth();
if (!session?.user?.impersonation) return null;
const { adminName, startedAt } = session.user.impersonation;
const startTime = new Date(startedAt).toLocaleTimeString();
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-white px-4 py-2 flex items-center justify-between text-sm font-medium shadow-md">
<div className="flex items-center gap-2">
<span className="text-amber-900">β οΈ</span>
<span>
You are viewing this account as{" "}
<strong>{session.user.name}</strong>. Impersonated by{" "}
<strong>{adminName}</strong> since {startTime}.
</span>
</div>
<form action={stopImpersonation}>
<button
type="submit"
className="bg-white text-amber-700 font-semibold px-3 py-1 rounded-md text-xs hover:bg-amber-50 transition-colors"
>
Exit impersonation
</button>
</form>
</div>
);
}
// app/layout.tsx β include banner at top level
import { ImpersonationBanner } from "@/components/impersonation-banner";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ImpersonationBanner />
<div className="pt-0 data-[impersonating=true]:pt-10">
{children}
</div>
</body>
</html>
);
}
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
Admin Panel: Impersonate Button
// app/admin/users/[userId]/page.tsx
import { startImpersonation } from "@/app/actions/impersonation";
import { ImpersonateForm } from "@/components/admin/impersonate-form";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminUserPage({
params,
}: {
params: { userId: string };
}) {
const session = await auth();
if (session?.user?.role !== "admin") redirect("/dashboard");
const user = await prisma.user.findUniqueOrThrow({
where: { id: params.userId },
select: {
id: true, name: true, email: true, role: true,
createdAt: true, workspace: { select: { name: true } },
},
});
const recentImpersonations = await prisma.impersonationLog.findMany({
where: { targetUserId: params.userId },
orderBy: { startedAt: "desc" },
take: 5,
include: { adminUser: { select: { name: true } } },
});
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">{user.name}</h1>
<p className="text-gray-500">{user.email} Β· {user.workspace.name}</p>
</div>
{/* Only show impersonate button for non-admin users */}
{user.role !== "admin" && (
<ImpersonateForm targetUserId={user.id} targetUserName={user.name} />
)}
{/* Impersonation history */}
{recentImpersonations.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-gray-700 mb-3">
Recent impersonation sessions
</h2>
<div className="space-y-2">
{recentImpersonations.map((log) => (
<div key={log.id} className="text-sm bg-gray-50 rounded-lg px-4 py-3">
<p className="text-gray-900">
Impersonated by <strong>{log.adminUser.name}</strong>
</p>
<p className="text-gray-500 text-xs mt-1">
{log.startedAt.toLocaleString()} Β·{" "}
{log.endedAt
? `Ended ${log.endedBy} at ${log.endedAt.toLocaleString()}`
: "Session active"}
</p>
<p className="text-gray-600 text-xs mt-1">Reason: {log.reason}</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
// components/admin/impersonate-form.tsx
"use client";
import { useTransition, useState } from "react";
import { startImpersonation } from "@/app/actions/impersonation";
export function ImpersonateForm({
targetUserId,
targetUserName,
}: {
targetUserId: string;
targetUserName: string;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await startImpersonation({ targetUserId, reason });
if (result?.error) setError(result.error);
});
}
return (
<div className="border border-amber-200 bg-amber-50 rounded-xl p-6">
<h2 className="font-semibold text-amber-900 mb-1">
Impersonate {targetUserName}
</h2>
<p className="text-xs text-amber-700 mb-4">
This action is logged. You will see the app exactly as this user sees it.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-amber-800 mb-1">
Reason for impersonation *
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Support ticket #1234 β user reports dashboard not loading"
rows={3}
className="w-full border border-amber-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
required
minLength={10}
/>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<button
type="submit"
disabled={isPending || reason.length < 10}
className="px-4 py-2 bg-amber-600 text-white text-sm font-semibold rounded-lg hover:bg-amber-700 disabled:opacity-50"
>
{isPending ? "Startingβ¦" : "Start impersonation β"}
</button>
</form>
</div>
);
}
Middleware: Guard Impersonated Actions
Some actions should never be available during impersonation (changing email, deleting account):
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
// Routes that impersonators cannot access
const IMPERSONATION_BLOCKED_ROUTES = [
"/settings/account/delete",
"/settings/email",
"/settings/password",
"/billing/cancel",
];
export async function middleware(request: NextRequest) {
const session = await getSession(request);
if (session?.impersonation) {
const isBlocked = IMPERSONATION_BLOCKED_ROUTES.some((route) =>
request.nextUrl.pathname.startsWith(route)
);
if (isBlocked) {
return NextResponse.redirect(
new URL("/dashboard?error=impersonation_blocked", request.url)
);
}
}
return NextResponse.next();
}
Audit Query: Recent Impersonation Activity
SELECT
il.id,
admin_u.name AS admin_name,
admin_u.email AS admin_email,
target_u.name AS target_name,
target_u.email AS target_email,
il.reason,
il.ip_address,
il.started_at,
il.ended_at,
il.ended_by,
EXTRACT(EPOCH FROM (COALESCE(il.ended_at, NOW()) - il.started_at)) / 60
AS duration_minutes
FROM impersonation_log il
JOIN users admin_u ON admin_u.id = il.admin_user_id
JOIN users target_u ON target_u.id = il.target_user_id
WHERE il.started_at >= NOW() - INTERVAL '30 days'
ORDER BY il.started_at DESC
LIMIT 50;
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic impersonation (no audit trail) | 1 dev | 1β2 days | $400β800 |
| Full impersonation with audit log + banner | 1β2 devs | 3β4 days | $1,200β2,400 |
| + Middleware guards + blocked routes + admin panel UI | 2 devs | 1 week | $3,000β5,000 |
See Also
- SaaS Audit Logging
- SaaS Role-Based Access Control
- SaaS Audit Trail
- Next.js Middleware Auth Patterns
- PostgreSQL Row-Level Security
Working With Viprasol
Impersonation done wrong is a security liability. Impersonation done right is the most effective support tool your team has. Our team builds impersonation systems that enforce privilege escalation guards (no impersonating admins), require a written reason before starting, embed impersonation metadata in the session token (not a separate lookup), display a persistent amber banner, and write a full audit trail with start/end times and reason.
What we deliver:
impersonation_logschema with admin/target/reason/ip/started_at/ended_atstartImpersonationServer Action: role check, self-impersonation guard, admin-impersonation guard, audit log entry, session rewritestopImpersonation: update log ended_at/ended_by, restore admin session, redirectImpersonationBanner: amber fixed banner with exit button (Server Component)- Middleware: block sensitive routes (delete account, email change) during impersonation
Talk to our team about your admin tooling requirements β
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.