Docs

Impersonation in Expo

User Impersonation is a way to offer customer support by logging into your applications from their accounts. Doing so enables you to directly reproduce and remedy any issues they're experiencing. In this example, you'll learn how to build Clerk's user impersonation into your Expo application.

Before you start

  1. Follow Clerk's Expo quickstart to ensure your project is configured properly.

  2. For simplifying the code example and getting started with development, this is using the new, experimental Expo API Routes. Please review https://docs.expo.dev/router/reference/api-routes/ for more information. You can use your own API server instead of these Expo API routes.

  3. You must configure your Clerk instance to enable Email and Password authentication:

    1. Navigate to the Clerk Dashboard in the navigation sidebar, go to User & Authentication > Email, Phone, Username.
    2. Ensure that Email address is required, and Username is not required.
    3. Ensure that Password is required.
  4. Install the following dependencies:

    terminal
    npm i expo-linking && npm i @clerk/types
    terminal
    yarn add expo-linking && yarn add @clerk/types
    terminal
    pnpm add expo-linking && pnpm add @clerk/types
  5. Now that Clerk is configured, you'll need to deploy to Vercel.

Warning

It is recommended that you should build impersonation into an admin-only dashboard that only authorized users can access.

For this example, create a basic dashboard for impersonating users.

The person visiting this dashboard should not be able to see it unless signed in. Start by creating the (dashboard) route group and adding the following code to the layout:

app/(dashboard)/_layout.tsx
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";

export default function GuestLayout() {
  const { isSignedIn } = useAuth();

  if (!isSignedIn) {
    return <Redirect href={"/dashboard"} />;
  }

  return <Stack />;
}

Create a (dashboard) page

Create the main page of the dashboard, which will hold most of your impersonation code. This example contains the skeleton of the final result, which will be fleshed out with each step in this guide.

Note

To skip this process, see the full example at the end of this guide.

app/(dashboard)/index.tsx
import React from "react";
import { Button, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Link, useRouter } from "expo-router";
import { useAuth, useUser, useSignIn, useSessionList } from "@clerk/clerk-expo";
import { UserDataJSON } from "@clerk/types";
import * as Linking from "expo-linking";
import useImpersonatedUser from "@/hooks/useImpersonatedUser"
import useImpersonation from "@/hooks/useImpersonation";

export default function Dashboard() {
	// This state will allow us to display some information about the current impersonator
	const [impersonator, setImpersonator] = React.useState<UserDataJSON | string>("");
	// Our Clerk hooks provide us with variables and functions to handle our authentication flows
  const { signOut, actor } = useAuth();
  const { isLoaded, signIn, setActive } = useSignIn();
  const { user } = useUser();
  const { sessions } = useSessionList();

  // Using expo's router will allow us to navigate properly once we've newly authenticated
  const router = useRouter();

  const actorRes = useImpersonation(actor?.sub || undefined, user?.id);
  const actorUserData = useImpersonatedUser(actor?.sub || "", setImpersonator);

  // Our actor endpoint will return a URL containing the ticket value we need to pass to our signIn method
  // This function will get fleshed out later in the doc
  function extractTicketValue() {}

  // `onSignoutPress` will determine which session we are attempting to sign out of and clear the session
  // This function will get fleshed out later in the doc
	async function onSignoutPress() {}

	return (
		<View>
      <Link href="/account">
        <Text>Account</Text>
      </Link>
      <Text>Hello {user?.firstName}</Text>

      {sessions?.map((sesh) => (
        <TouchableOpacity
          onPress={() => onSignOutPress(sesh.id)}
          key={sesh.id}
        >
          <Text>
            Sign out of {sesh?.user?.primaryEmailAddress?.emailAddress}
          </Text>
        </TouchableOpacity>
      ))}

      {actorRes && (
        <Button
          title="Impersonate"
          onPress={async () => await impersonateUser()}
        />
      )}
    </View>
	)
}

Create a hook to generate an actor token

Create a custom hook called useImpersonation() that will call an API route to generate and return an actor token from Clerk.

hooks/useImpersonation

import { useState, useEffect } from "react";

// This is the entire return type for actor token generation,
// Though we are only using a couple of the properties in the example
export type Actor = {
  object: string;
  id: string;
  status: "pending" | "accepted" | "revoked";
  user_id: string;
  actor: object;
  token: string | null;
  url: string | null;
  created_at: Number;
  updated_at: Number;
};

export default function useImpersonation(
  actorId: string | undefined,
  userId: string | undefined
) {
  const [actor, setActor] = useState<Actor>();

  useEffect(() => {
    async function generateAndSetToken() {
      if (typeof actorId !== "string") {
        const res = await fetch("/generateActorToken", {
          method: "POST",
          body: JSON.stringify({
            // This is the user ID of the use you're going to impersonate
            user_id: userId,
            actor: {
              // This is the ID of the impersonator
              sub: actorId,
            },
          }),
        });

        const data = await res.json();

        setActor(data);
      }
    }

    generateAndSetToken();
  }, []);

  return actor;
}

Create an API route to generate actor tokens

Now create an endpoint that will call Clerk's create actor token endpoint at /actor_tokens and pass in the Clerk secret key for authorization. In your API, you should build in permission checks to make sure this is only being accessed from a trusted source.

app/generateActorToken+api.tsx
export async function POST(request: Request) {
  const body: { user_id: string; actor: { sub: string } } =
    await request.json();

  const { user_id, actor } = body;

  const res = await fetch("https://api.clerk.com/v1/actor_tokens", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CLERK_SECRET_KEY}`,
      "Content-type": "application/json",
    },
    body: JSON.stringify({
      user_id,
      actor: {
        sub: actor.sub,
      },
    }),
  });

  const data = await res.json();

  return Response.json(data);
}

Create a hook to get impersonated user data

Create a second custom hook called useImpersonatedUser() that will fetch data about the impersonator. You can use this to display UI only the impersonator will see for specific actions, like showing who is currently impersonating and matching the session to the right user for logout.

hooks/useImpersonatedUser
function useImpersonatedUser(
  actorSub: string,
  setImpersonator: React.Dispatch<React.SetStateAction<string | UserDataJSON>>
) {
  React.useEffect(() => {
    const getImpersonatedUser = async () => {
      const res = await fetch(`/getImpersonatedUser`, {
        method: "POST",
        body: JSON.stringify({
          impersonator_id: actorSub,
        }),
      });

      const data = await res.json();

      setImpersonator(data);

      getImpersonatedUser();
    };
  }, [actorSub]);
}

Create an API route to retrieve user data

With your hook setup you can now create an API route that will call Clerk's retrieve user endpoint at /users and get back the Impersonated user's full User object.

app/getImpersonatedUser+api.tsx
export async function POST(request: Request) {
  const body: { impersonator_id: string } = await request.json();

  const { impersonator_id } = body;
  const res = await fetch(`https://api.clerk.com/v1/users/${impersonator_id}`, {
    headers: {
      Authorization: `Bearer ${process.env.CLERK_SECRET_KEY}`,
    },
  });
  const data = await res.json();

  return Response.json(data);
}

Create functions to generate an impersonated session

Now that you have the functionality for generating an impersonated session & getting the impersonated user's data, there are a few more functions to complete that will:

  1. Get the ticket from the URL generated by your useImpersonation() hook.
  2. Pass the ticket to Clerk's signIn() function to create a new impersonated session, allowing you to sign in the impersonated user or impersonator.

The following code should be placed in the Page component of your app/(dashboard)/index.tsx file:

app/(dashboard)/index.tsx
  // Use this function to get the ticket ID from the response of our generateActorToken API
  function extractTicketValue(input: string): string | undefined {
    const index = input.indexOf("ticket=");
    if (index !== -1) {
      return input.slice(index + 7);
    }
    return undefined;
  }

	// Passing the ticket ID to our `signIn.create` function will create a new impersonated session that we can set to the active session
  async function impersonateUser() {
    if (!isLoaded) return;

    if (typeof actorRes?.url === "string") {
      const ticket = extractTicketValue(actorRes.url);

      if (ticket) {
        try {
          const { createdSessionId } = await signIn.create({
            strategy: "ticket",
            ticket,
          });

          await setActive({ session: createdSessionId });
          await user?.reload();

          router.replace("/dashboard");
        } catch (err) {
          // See https://clerk.com/docs/custom-flows/error-handling
          // for more info on error handling
          console.error(JSON.stringify(err, null, 2));
        }
      }
    }
  }

Create a hook for signing out

Finally, create a helper function for signing out. Usually this can be as simple as calling Clerk's signOut() function, but since you're handling multiple sessions, you must add some checks to ensure you're signing out of the right session.

app/(dashboard)/index.tsx
  const onSignOutPress = async (sessionId: string) => {
    try {
      if (isLoaded && sessions && sessions?.length > 0) {
        const noActiveSessions = sessions.filter(
          (session) => session.user?.id !== user?.id
        );
        await setActive({ session: noActiveSessions[0].id });
      }
      const redirectUrl = Linking.createURL("/dashboard", { scheme: "myapp" });
      await signOut({
        sessionId,
      });
      router.replace(redirectUrl);
    } catch (err: any) {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2));
    }
  };

Full example

The full code for the dashboard page will resemble the following, excluding the API routes:

app/(dashboard)/index.tsx
import React from "react";
import { Button, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Link, useRouter } from "expo-router";
import { useAuth, useUser, useSignIn, useSessionList } from "@clerk/clerk-expo";
import { UserDataJSON } from "@clerk/types";
import * as Linking from "expo-linking";

export type Actor = {
  object: string;
  id: string;
  status: "pending" | "accepted" | "revoked";
  user_id: string;
  actor: object;
  token: string | null;
  url: string | null;
  created_at: Number;
  updated_at: Number;
};

function useImpersonation(
  actorId: string | undefined,
  userId: string | undefined
) {
  const [actor, setActor] = React.useState<Actor>();
  React.useEffect(() => {
    async function generateAndSetToken() {
      if (typeof actorId !== "string") {
        const res = await fetch("/generateActorToken", {
          method: "POST",
          body: JSON.stringify({
            user_id: userId, // This is the user ID of the use you're going to impersonate,
            actor: {
              sub: actorId, // This is the ID of the impersonator,
            },
          }),
        });

        const data = await res.json();

        setActor(data);
      }
    }

    generateAndSetToken();
  }, []);

  return actor;
}

function useImpersonatedUser(
  actorSub: string,
  setImpersonator: React.Dispatch<React.SetStateAction<string | UserDataJSON>>
) {
  React.useEffect(() => {
    const getImpersonatedUser = async () => {
      const res = await fetch(`/getImpersonatedUser`, {
        method: "POST",
        body: JSON.stringify({
          impersonator_id: actorSub,
        }),
      });

      const data = await res.json();

      setImpersonator(data);

      getImpersonatedUser();
    };
  }, [actorSub]);
}

export default function Page() {
  const [impersonator, setImpersonator] = React.useState<UserDataJSON | string>(
    ""
  );
  const { signOut, actor } = useAuth();
  const { isLoaded, signIn, setActive } = useSignIn();
  const { user } = useUser();
  const router = useRouter();
  const { sessions } = useSessionList();

  const actorRes = useImpersonation(actor?.sub || undefined, user?.id);
  const actorUserData = useImpersonatedUser(actor?.sub || "", setImpersonator);

  function extractTicketValue(input: string): string | undefined {
    const index = input.indexOf("ticket=");
    if (index !== -1) {
      return input.slice(index + 7);
    }
    return undefined;
  }

  async function impersonateUser() {
    if (!isLoaded) return;

    if (typeof actorRes?.url === "string") {
      const ticket = extractTicketValue(actorRes.url);

      if (ticket) {
        try {
          const { createdSessionId } = await signIn.create({
            strategy: "ticket",
            ticket,
          });

          await setActive({ session: createdSessionId });
          await user?.reload();

          router.replace("/dashboard");
        } catch (err) {
          // See https://clerk.com/docs/custom-flows/error-handling
          // for more info on error handling
          console.error(JSON.stringify(err, null, 2));
        }
      }
    }
  }

  const onSignOutPress = async (sessionId: string) => {
    try {
      if (isLoaded && sessions && sessions?.length > 0) {
        const noActiveSessions = sessions.filter(
          (session) => session.user?.id !== user?.id
        );
        await setActive({ session: noActiveSessions[0].id });
      }
      const redirectUrl = Linking.createURL("/dashboard", { scheme: "myapp" });
      await signOut({
        sessionId,
      });
      router.replace(redirectUrl);
    } catch (err: any) {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2));
    }
  };

  return (
    <View>
      <Link href="/account">
        <Text>Account</Text>
      </Link>
      <Text>Hello {user?.firstName}</Text>

      {sessions?.map((sesh) => (
        <TouchableOpacity
          onPress={() => onSignOutPress(sesh.id)}
          key={sesh.id}
        >
          <Text>
            Sign out of {sesh?.user?.primaryEmailAddress?.emailAddress}
          </Text>
        </TouchableOpacity>
      ))}

      {actorRes && (
        <Button
          title="Impersonate"
          onPress={async () => await impersonateUser()}
        />
      )}
    </View>
  );
}

Feedback

What did you think of this content?