When a customer says "I cannot see my dashboard," your support team has two choices: ask for screenshots and hope they capture the right thing, or see exactly what the customer sees.
The second option closes support tickets in minutes instead of hours. User impersonation lets an admin issue a short-lived JWT that scopes them into a specific user account -- no password sharing, no production database edits, no guessing.
Here is how to build it safely in a Next.js SaaS with Drizzle ORM.
Add an is_admin flag to your users table and create a dedicated audit table for impersonation sessions.
// modules/user/user.schema.ts -- add is_admin column
export const userTable = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// modules/impersonation/impersonation.schema.ts
export const impersonationTable = pgTable("impersonation_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
adminId: uuid("admin_id").notNull().references(() => userTable.id),
targetUserId: uuid("target_user_id").notNull().references(() => userTable.id),
startedAt: timestamp("started_at").notNull().defaultNow(),
endedAt: timestamp("ended_at"),
});
Run npm run db:generate && npm run db:migrate to apply both.
A regular login token carries { sub: userId }. An impersonation token adds two extra claims: the admin ID (for audit) and a flag that lets any route handler or UI component know the session is impersonated.
// lib/auth.ts
export function signImpersonationToken(adminId: string, targetUserId: string) {
return jwt.sign(
{ sub: targetUserId, adminId, impersonating: true },
process.env.JWT_SECRET!,
{ expiresIn: "1h" }
);
}
Because sub is the target user, every downstream permission check works unchanged -- the impersonating admin sees exactly the same data the user would.
Extend getUserFromRequest to expose the extra claims:
export function getUserFromRequest(req: Request) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) throw new HttpError(401, "Unauthorized");
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
return {
id: payload.sub as string,
adminId: payload.adminId as string | undefined,
impersonating: payload.impersonating === true,
};
}
One POST endpoint, admin-only. It records the session start and returns the scoped token.
// app/api/admin/impersonate/route.ts
export async function POST(req: Request) {
try {
const caller = getUserFromRequest(req);
const admin = await userService.getById(caller.id);
if (!admin.isAdmin) throw new HttpError(403, "Forbidden");
const { targetUserId } = await req.json();
await impersonationService.start(caller.id, targetUserId);
const token = signImpersonationToken(caller.id, targetUserId);
return Response.json({ token });
} catch (error: unknown) {
return handleError(error);
}
}
The client stores this token the same way it stores the regular JWT. Swapping in the impersonation token is enough -- no other state to change.
A second endpoint records when the session ends and lets the client know it should restore the admin token.
// app/api/admin/impersonate/exit/route.ts
export async function POST(req: Request) {
try {
const caller = getUserFromRequest(req);
if (!caller.impersonating) throw new HttpError(400, "Not impersonating");
await impersonationService.end(caller.adminId!, caller.id);
return Response.json({ ok: true });
} catch (error: unknown) {
return handleError(error);
}
}
On the client, clicking "Exit" calls this endpoint and swaps the stored token back to the admin JWT. No re-login, no friction.
A server component at layout level reads the impersonation flag and renders a persistent bar so nobody forgets they are in a customer account.
// components/layout/ImpersonationBanner.tsx
export function ImpersonationBanner({ impersonating }: { impersonating: boolean }) {
if (!impersonating) return null;
return (
<div className="bg-yellow-500 text-black text-sm text-center py-2 px-4 font-medium">
You are viewing this account as an admin -- actions you take are real.
</div>
);
}
Pass the flag down from your root layout where you already decode the current user.
Building this end-to-end means wiring the JWT library, the admin flag migration, the audit schema, the impersonation service, two API routes, and the client-side token swap -- across parts of the codebase that need to stay in sync. Most teams spend a full day on it and still miss the audit log.
Claude Code Boilerplate ships with the auth layer, Drizzle ORM setup, service patterns, and error handling already in place. You drop in the impersonation module on top of a foundation that already knows how JWT, services, and routes fit together. Start to shipped: under 2 hours.
Clone it, add the impersonation module, and have it running before your next customer support call. Try it free at boilerplate.iteam-company.com.