Joonas' blog

Implement user impersonation in Supabase

published
This method uses PKCE authentication flow, and thus requires `@supabase/auth-helpers-nextjs` v. `>=0.7.0` if you're using Next.js

Supabase auth system and its simplicity work well for implementing web applications, but at some point app complexity reaches the point where you need to debug app behavior as another user. There’s a few options for achieving this kind of behavior:

  1. Add RLS policies to allow admins to access all data regardless of any permission systems
  2. Create an admin mode, where you allow admin sessions to access all data via service role through serverside requests
  3. Create an impersonation mode, which allows completely logging in as another user and acting as if you were them

I recently worked on a production app that started with strategy 1 using special RLS policies to allow all to admins. While it’s a quick method to allow admin access, it suffers from being hard to audit, hard to know if what users see matches what admins see (for example forgotten RLS policies to allow regular users to see some data), and generally just doesn’t feel robust.

Therefore, the admin access mode was gradually migrated to option 3 with an explicit impersonation process and it has been much nicer to use and develop for, as the need for special code paths has generally decreased.

Impersonation mode in short

To implement an impersonation mode, we’ll want to use Supabase auth client to generate magic links to allow us to sign as other users, while still retaining the knowledge that we’re impersonating someone.

Namely:

  1. Start impersonation by generating a magic link for impersonatee and redirect admin user to it
  2. Set a cookie to mark session as impersonating
  3. When requested, stop impersonation session by creating yet another magic link to sign the user back in as an admin

Start impersonation

The starting point is where should impersonation be started from. For this I recommend creating some kind of admin dashboard/panel, where you can see a list of users and easily start impersonation from there.

Another good place to add impersonation might be 401 unauthorized error pages: if you detect an admin user trying to access a page they ordinarily don’t have access to, chances are they are trying to debug a customer issue.You might want to consider adding some kind of one-click impersonate button there to make debugging customer issues much easier, if that fits the scope of the app.

When we have a place in the UI to start impersonation from, we can use supabase-js on server to generate a login link for the wanted user. That means we can just generate and immediately redirect the user to a magic login link, and thus have them sign in as the impersonated user.

// this needs to be done serverside
const impersonatedUserEmail = "impersonate@me.com";
const impersonationLoginGeneration = await supabase.auth.admin.generateLink({
type: "magiclink",
email: impersonatedUserEmail,
});
const tokenHash = impersonationLoginGeneration.data.properties.hashed_token;
const searchParams = new URLSearchParams({
token_hash: tokenHash,
next: "/"
})
const impersonationLoginLink = `/auth/confirm?${searchParams}`;
// redirect using web framework of choice
redirect(impersonationLoginUrl);

We’ll also have to create an API route to handle serverside verification of the token in accordance to PKCE authentication flow.

You might already have one, if you’re using PKCE flow for user sign-in. But if not, here’s how to implement one in Next.js:

app/auth/confirm/route.ts

import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET(req) {
const { searchParams } = new URL(req.url)
const token_hash = searchParams.get('token_hash')
const next = searchParams.get('next') ?? '/'
if (token_hash) {
const supabase = createRouteHandlerClient({ cookies })
const { error } = await supabase.auth.verifyOtp({ type: "magiclink", token_hash })
if (!error) {
return NextResponse.redirect(new URL(`/${next.slice(1)}`, req.url))
}
}
// return the user to an error page with some instructions
return NextResponse.redirect(new URL('/auth/auth-code-error', req.url))
}

These are enough to completely handle the impersonator->impersonatee authenticated user flow.

Give admins their admin superpowers

While impersonation mode in itself is already quite useful for debugging customer data and user interfaces, sometimes admins just need to do operations that exceeds normally permissible operations. This brings us to one of the issues with impersonation compared to other methods: since we fully absorb the identity of the impersonated user, we don’t have any way to detect whether the user is an admin or not.

During my exploration of impersonate mode, I looked into using e.g. user_metadata to store user admin status. However, methods related to editing anything related to the impersonated user itself didn’t seem very feasible or robust. But then I figured you can just store an additional JWT token that is purely dedicated to impersonation session information, and use that to retrieve impersonation status whenever needed.

Here’s a simplified version of impersonation JWT management:

// impersonation jwt related code
import crypto from "crypto";
import { createSigner, createVerifier } from "fast-jwt";
const IMPERSONATION_JWT_KEY = crypto.randomBytes(32);
const impersonationJwtSigner = createSigner({ key: IMPERSONATION_JWT_KEY });
const impersonationJwtVerifier = createVerifier({ key: IMPERSONATION_JWT_KEY });
// again in impersonation start code
const impersonationLoginUrl = ...;
const jwt = await impersonationJwtSigner({
admin_email: user.email
});
cookies().set("admin-impersonation", jwt, {
path: "/",
httpOnly: false
});
redirect(impersonationLoginUrl);

Here we generate a simple JWT object with the admin_email and set it in cookies. Then on clientside we can simply check for existence (and validity) of the impersonation cookie to decide whether we want to show admin-only UI elements. For serverside admin action handling, we can again check and verify the impersonation cookie’s JWT contents.

Stop impersonation

We’ll also want a way to stop impersonation when we’re done with impersonation duties.

Assuming that we created an impersonation token, we can just check for its existence and in that case use “reverse-impersonation” to sign back in as the admin user.

// this also needs to be done serverside
const cookie = cookies.get("admin-impersonation");
if (!cookie) {
return res.status(400).send("no impersonation cookie");
}
cookies.delete("admin-impersonation");
const token = impersonationJwtVerifier(cookie);
const unimpersonateLoginGeneration = await supabase.auth.admin.generateLink({
type: "magiclink",
email: token.admin_email,
});
const tokenHash = unimpersonateLoginGeneration.data.properties.hashed_token;
const searchParams = new URLSearchParams({
token_hash: tokenHash,
next: "/"
})
const unimpersonateLoginLink = `/auth/confirm?${searchParams}`;
redirect(unimpersonateLoginLink);

Note that we’re re-using the /auth/confirm route handler from above to handle token verification.

Done

That’s it! These steps should be enough to implement a usable impersonation flow with Supabase auth.