Back to Blog

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.

Viprasol Tech Team
May 11, 2027
12 min read

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:

  1. userId β€” the target user being impersonated
  2. impersonatedBy β€” 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

ScopeTeamTimelineCost Range
Basic impersonation (no audit trail)1 dev1–2 days$400–800
Full impersonation with audit log + banner1–2 devs3–4 days$1,200–2,400
+ Middleware guards + blocked routes + admin panel UI2 devs1 week$3,000–5,000

See Also


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_log schema with admin/target/reason/ip/started_at/ended_at
  • startImpersonation Server Action: role check, self-impersonation guard, admin-impersonation guard, audit log entry, session rewrite
  • stopImpersonation: update log ended_at/ended_by, restore admin session, redirect
  • ImpersonationBanner: 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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.