Implement user impersonation
Enable user impersonation for testing and support by creating an admin-protected API endpoint.
Overview
Impersonating a user allows you to login as them without using their credentials. This is useful for testing purposes, or for customer support.
This guide shows you how to achieve this by only allowing a certain type of users, admins, to perform the impersonation.
Before you start
For production use, apply these additional controls:
- Require a recent step-up authentication before each impersonation starts. For example, require the staff member to complete MFA again.
- Require a reason and write append-only audit events for every attempt and outcome. Include the actor’s user ID, target user ID, reason, timestamp, outcome, and impersonation session handle. Do not rely only on a custom access token claim as the audit record. Fail closed if the start event cannot be recorded.
- Set a short maximum duration. Enforce the deadline on the backend, revoke the impersonation session when it expires, and provide an explicit way to terminate it early. Monitor and alert on unusual impersonation activity.
- Decide which targets and actions staff may access while impersonating. For example, prevent impersonation of other administrators and block credential, MFA, payment, and destructive account changes unless your policy explicitly allows them.
Steps
1. Create the impersonation endpoint
Create a new API endpoint that accepts a stable user ID and creates a new impersonation session for that user. If you instead use an email address, phone number, or other account information, require the lookup to return exactly one user; never select the first of multiple matches.
In order for this to work, admins need to first log in to the application as themselves. Once they create their session (like any regular user’s session), they can call the API via a frontend UI that’s only shown to them. You can detect the admin role on the frontend by seeing this guide.
import express from "express";
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
let app = express();
app.post(
"/impersonate",
verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
async (req, res) => {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(req, res, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
res.json({ message: "Impersonation successful!" });
},
);import Hapi from "@hapi/hapi";
import { verifySession } from "supertokens-node/recipe/session/framework/hapi";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
let server = Hapi.server({ port: 8000 });
server.route({
path: "/impersonate",
method: "post",
options: {
pre: [
{
method: verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
},
],
},
handler: async (req, res) => {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(req, res, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
return res.response({ message: "Impersonation successful!" }).code(200);
},
});import Fastify from "fastify";
import { verifySession } from "supertokens-node/recipe/session/framework/fastify";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
let fastify = Fastify();
fastify.post(
"/impersonate",
{
preHandler: verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
},
async (req, res) => {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(req, res, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
res.send({ message: "Impersonation successful!" });
},
);import { verifySession } from "supertokens-node/recipe/session/framework/awsLambda";
import { middleware } from "supertokens-node/framework/awsLambda";
import Session from "supertokens-node/recipe/session";
import { SessionEvent } from "supertokens-node/framework/awsLambda";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
async function impersonate(awsEvent: SessionEvent) {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(awsEvent, awsEvent, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
return {
body: JSON.stringify({ message: "Impersonation successful!" }),
statusCode: 200,
};
}
exports.handler = verifySession(impersonate, {
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
});import KoaRouter from "koa-router";
import { verifySession } from "supertokens-node/recipe/session/framework/koa";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
let router = new KoaRouter();
router.post(
"/impersonate",
verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
async (ctx, next) => {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(ctx, ctx, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
ctx.body = { message: "Impersonation successful!" };
},
);import { inject, intercept } from "@loopback/core";
import { RestBindings, MiddlewareContext, post, response } from "@loopback/rest";
import { verifySession } from "supertokens-node/recipe/session/framework/loopback";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
class Login {
constructor(@inject(RestBindings.Http.CONTEXT) private ctx: MiddlewareContext) {}
@post("/impersonate")
@intercept(
verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
)
@response(200)
async handler() {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await Session.createNewSession(this.ctx, this.ctx, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
return { message: "Impersonation successful!" };
}
}import { superTokensNextWrapper } from "supertokens-node/nextjs";
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import { createNewSession } from "supertokens-node/recipe/session";
import { SessionRequest } from "supertokens-node/framework/express";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
export default async function impersonate(req: SessionRequest, res: any) {
await superTokensNextWrapper(
async (next) => {
await verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
})(req, res, next);
},
req,
res,
);
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await superTokensNextWrapper(
async (next) => {
await createNewSession(req, res, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
},
req,
res,
);
res.json({
message: "Impersonation successful!",
});
}import { Controller, Post, Res, Req, UseGuards } from "@nestjs/common";
import type { Response, Request } from "express";
import { AuthGuard } from "./auth/auth.guard";
import { createNewSession, SessionContainer, SessionClaimValidator } from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";
@Controller()
export class ExampleController {
// For more information about "AuthGuard" and the "Session" decorator please read our NestJS guide.
@Post("impersonate")
@UseGuards(
new AuthGuard({
overrideGlobalClaimValidators: async (globalValidators: SessionClaimValidator[]) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
)
async postLogin(@Req() req: Request, @Res() res: Response): Promise<{ message: string }> {
let email = "..."; // read from request body
let user = await supertokens.listUsersByAccountInfo("public", {
email,
});
if (user.length !== 1) {
throw new Error("Identifier does not uniquely identify a user");
}
await createNewSession(req, res, "public", user[0].loginMethods[0].recipeUserId, {
isImpersonation: true,
});
return { message: "Impersonation successful!" };
}
}import (
"net/http"
"github.com/supertokens/supertokens-golang/recipe/emailpassword"
"github.com/supertokens/supertokens-golang/recipe/session"
"github.com/supertokens/supertokens-golang/recipe/session/claims"
"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
"github.com/supertokens/supertokens-golang/recipe/userroles/userrolesclaims"
"github.com/supertokens/supertokens-golang/supertokens"
)
func main() {
_ = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
session.VerifySession(&sessmodels.VerifySessionOptions{
OverrideGlobalClaimValidators: func(globalClaimValidators []claims.SessionClaimValidator, sessionContainer sessmodels.SessionContainer, userContext supertokens.UserContext) ([]claims.SessionClaimValidator, error) {
globalClaimValidators = append(globalClaimValidators, userrolesclaims.UserRoleClaimValidators.Includes("admin", nil, nil))
return globalClaimValidators, nil
},
}, impersonate).ServeHTTP(rw, r)
})
}
func impersonate(w http.ResponseWriter, r *http.Request) {
email := "..." // read from request body
// we are using emailpassword recipe here, but you can use the recipe you use
// as well..
user, err := emailpassword.GetUserByEmail("public", email)
if err != nil {
// Send 500 to client
return
}
if user == nil {
// Send 400 to client cause user does not exist
return
}
_, err = session.CreateNewSession(r, w, "public", user.ID, map[string]interface{}{
"isImpersonation": true,
}, nil)
if err != nil {
err = supertokens.ErrorHandler(err, r, w)
if err != nil {
// Send 500 to client
}
return
}
// Send 200 success to client
}from fastapi import Depends, Request
from fastapi.responses import JSONResponse
from supertokens_python.asyncio import list_users_by_account_info
from supertokens_python.recipe.session import SessionContainer
from supertokens_python.recipe.session.asyncio import create_new_session
from supertokens_python.recipe.session.framework.fastapi import verify_session
from supertokens_python.recipe.userroles import UserRoleClaim
from supertokens_python.types.base import AccountInfoInput
@app.post("/impersonate")
async def impersonate(
request: Request,
session: SessionContainer = Depends(
verify_session(
# We add the UserRoleClaim's includes validator
override_global_claim_validators=lambda global_validators,
session,
user_context: global_validators
+ [UserRoleClaim.validators.includes("admin")]
)
),
):
email = "..." # get from request body
# we use the email password recipe here, but you can use the recipe you use
user = await list_users_by_account_info("public", AccountInfoInput(email=email))
if len(user) != 1:
# return a 400 error because the identifier is missing or ambiguous
return
await create_new_session(
request,
"public",
user[0].login_methods[0].recipe_user_id,
{"isImpersonation": True},
)
return JSONResponse({"message": "Impersonation complete!"})from flask import jsonify
from flask.wrappers import Request
from supertokens_python.recipe.session.framework.flask import verify_session
from supertokens_python.recipe.session.syncio import create_new_session
from supertokens_python.recipe.userroles import UserRoleClaim
from supertokens_python.syncio import list_users_by_account_info
from supertokens_python.types.base import AccountInfoInput
@app.route("/impersonate", methods=["POST"])
@verify_session(
# We add the UserRoleClaim's includes validator
override_global_claim_validators=lambda global_validators,
session,
user_context: global_validators + [UserRoleClaim.validators.includes("admin")]
)
def login(request: Request):
email = "..." # get from request body
# we use the email password recipe here, but you can use the recipe you use
user = list_users_by_account_info("public", AccountInfoInput(email=email))
if len(user) != 1:
# return a 400 error because the identifier is missing or ambiguous
return
create_new_session(
request,
"public",
user[0].login_methods[0].recipe_user_id,
{"isImpersonation": True},
)
return jsonify({"message": "Impersonation complete!"})from django.http import HttpRequest, JsonResponse
from supertokens_python.asyncio import list_users_by_account_info
from supertokens_python.recipe.session.asyncio import create_new_session
from supertokens_python.recipe.session.framework.django.asyncio import verify_session
from supertokens_python.recipe.userroles import UserRoleClaim
from supertokens_python.types.base import AccountInfoInput
@verify_session(
# We add the UserRoleClaim's includes validator
override_global_claim_validators=lambda global_validators,
session,
user_context: global_validators + [UserRoleClaim.validators.includes("admin")]
)
async def impersonate(request: HttpRequest):
email = "..." # get from request body
# we use the email password recipe here, but you can use the recipe you use
user = await list_users_by_account_info("public", AccountInfoInput(email=email))
if len(user) != 1:
# return a 400 error because the identifier is missing or ambiguous
return
await create_new_session(
request,
"public",
user[0].login_methods[0].recipe_user_id,
{"isImpersonation": True},
)
return JsonResponse({"message": "User logged in!"})import { NextResponse, NextRequest } from "next/server";
import SuperTokens from "supertokens-node";
import { withPreParsedRequestResponse } from "supertokens-node/nextjs";
import { CollectingResponse, PreParsedRequest } from "supertokens-node/framework/custom";
import Session, { createNewSession } from "supertokens-node/recipe/session";
import UserRoles from "supertokens-node/recipe/userroles";
import { backendConfig } from "@/app/config/backend";
import {
assertRecentImpersonationStepUp,
createImpersonationAttemptId,
getAuthorizedImpersonationTenant,
getImpersonationExpiry,
recordImpersonationAuditEvent,
registerImpersonationExpiry,
} from "@/app/auth/impersonation-security";
SuperTokens.init(backendConfig());
function copyCollectedCredentials(source: CollectingResponse, destination: CollectingResponse) {
source.headers.forEach((value, key) => destination.setHeader(key, value, false));
for (const cookie of source.cookies) {
destination.setCookie(
cookie.key,
cookie.value,
cookie.domain,
cookie.secure,
cookie.httpOnly,
cookie.expires,
cookie.path,
cookie.sameSite,
);
}
}
export function POST(request: NextRequest) {
return withPreParsedRequestResponse(
request,
async (baseRequest: PreParsedRequest, baseResponse: CollectingResponse) => {
const actorSession = await Session.getSession(baseRequest, baseResponse, {
sessionRequired: true,
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
});
const actorUserId = actorSession.getUserId();
const attemptId = createImpersonationAttemptId();
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
outcome: "ATTEMPT_STARTED",
});
try {
await assertRecentImpersonationStepUp(actorSession);
} catch {
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
outcome: "STEP_UP_FAILED",
});
return NextResponse.json({ message: "Impersonation request denied" }, { status: 403 });
}
// This application-owned helper derives allowed tenants from trusted
// server-side staff entitlements. It must not trust a request-body tenant ID.
let tenantId: string;
try {
tenantId = await getAuthorizedImpersonationTenant({
actorUserId,
actorSessionTenantId: actorSession.getTenantId(),
});
} catch {
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
outcome: "TENANT_AUTHORIZATION_FAILED",
});
return NextResponse.json({ message: "Impersonation request denied" }, { status: 403 });
}
// Authorization and step-up have succeeded. Only now read the target and reason.
let targetRecipeUserId = "..."; // validate and read a stable recipe user ID from the request body
let reason = "..."; // require a non-empty support or incident reason
let targetUser: Awaited<ReturnType<typeof SuperTokens.getUser>>;
try {
targetUser = await SuperTokens.getUser(targetRecipeUserId);
} catch {
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
tenantId,
reason,
outcome: "TARGET_LOOKUP_FAILED",
});
return NextResponse.json({ message: "Impersonation request denied" }, { status: 400 });
}
const targetLoginMethod = targetUser?.loginMethods.find(
(loginMethod) => loginMethod.recipeUserId.getAsString() === targetRecipeUserId,
);
if (!targetUser || !targetLoginMethod?.tenantIds.includes(tenantId)) {
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
targetRecipeUserId,
tenantId,
reason,
outcome: "TARGET_DENIED",
});
return NextResponse.json({ message: "Impersonation request denied" }, { status: 400 });
}
let expiresAt: number;
try {
expiresAt = await getImpersonationExpiry({ actorUserId, tenantId });
} catch {
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
targetUserId: targetUser.id,
targetRecipeUserId,
tenantId,
reason,
outcome: "EXPIRY_DERIVATION_FAILED",
});
return NextResponse.json({ message: "Impersonation request denied" }, { status: 500 });
}
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
targetUserId: targetUser.id,
targetRecipeUserId,
tenantId,
reason,
outcome: "ATTEMPT_APPROVED",
expiresAt,
});
const stagedResponse = new CollectingResponse();
let impersonationSession: Awaited<ReturnType<typeof createNewSession>> | undefined;
let stage: "SESSION_CREATION" | "EXPIRY_REGISTRATION" | "SUCCESS_AUDIT" = "SESSION_CREATION";
try {
impersonationSession = await createNewSession(baseRequest, stagedResponse, tenantId, targetLoginMethod.recipeUserId, {
isImpersonation: true,
impersonatedBy: actorUserId,
impersonationExpiresAt: expiresAt,
});
stage = "EXPIRY_REGISTRATION";
await registerImpersonationExpiry({
sessionHandle: impersonationSession.getHandle(),
expiresAt,
});
stage = "SUCCESS_AUDIT";
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
targetUserId: targetUser.id,
targetRecipeUserId,
tenantId,
reason,
outcome: "SESSION_CREATED",
impersonationSessionHandle: impersonationSession.getHandle(),
expiresAt,
});
} catch {
let revoked: boolean | undefined;
if (impersonationSession) {
try {
revoked = await Session.revokeSession(impersonationSession.getHandle());
} catch {
revoked = false;
}
}
let failureOutcome = "SESSION_CREATION_FAILED";
if (stage === "EXPIRY_REGISTRATION") {
failureOutcome = "EXPIRY_REGISTRATION_FAILED";
} else if (stage === "SUCCESS_AUDIT") {
failureOutcome = "SUCCESS_AUDIT_FAILED";
}
await recordImpersonationAuditEvent({
attemptId,
actorUserId,
targetUserId: targetUser.id,
targetRecipeUserId,
tenantId,
reason,
outcome: failureOutcome,
impersonationSessionHandle: impersonationSession?.getHandle(),
revoked,
revocationFailed: revoked === false,
expiresAt,
});
return NextResponse.json({ message: "Impersonation request failed" }, { status: 500 });
}
// Staged credentials remain unreachable until every post-creation control succeeds.
copyCollectedCredentials(stagedResponse, baseResponse);
return NextResponse.json({ message: "Impersonation successful" });
},
);
}- The API should be called from your frontend application so that the frontend SDK’s network interceptors run.
- In the APIs above, required-session verification and backend admin-role validation run before the request reads or looks up the target. A missing or invalid session is rejected, and a session without the required role is rejected with
403. - Prefer a stable target user or recipe-user ID. If you look up by account information instead, reject zero or multiple matches rather than selecting the first result.
- A new session is then created using the target user’s user ID. The
isImpersonationflag is added to the access token payload so that the frontend can show that the staff member is impersonating a user. Backend APIs can also use this claim to restrict actions while impersonating. Treat claims such asisImpersonationandimpersonatedByas enforcement context, not as a durable audit log. - The new session tokens attach to the response and overwrite the active admin credentials in that browser. This does not revoke the original admin session in SuperTokens. Cookies apply if the request contains the
st-auth-mode: "cookie"header; otherwise, the mode is header-based authentication. The frontend interceptors set this header automatically. - Signing out revokes the current impersonation session. Also provide explicit early termination and enforce your maximum duration on the backend; do not depend on the user remembering to sign out.