BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. How to Add "Log In As User" Impersonation with Neon Auth in Next.js

How to Add "Log In As User" Impersonation with Neon Auth in Next.js

A production guide to user impersonation with Neon Auth (Better Auth) and Next.js, including the __Secure- cookie…

29th September 2026·Updated on:30th September 2026·MŽMatija Žiberna·
Next.js
How to Add "Log In As User" Impersonation with Neon Auth in Next.js

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide
  • •How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js
  • •Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide
📄View markdown version
0

Frequently Asked Questions

Comments

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide
Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide

27th September 2026

How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js
How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js

26th September 2026

Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide
Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide

3rd February 2026

Contents

  • How user impersonation works in principle
  • Why impersonation needs a service account
  • Step 1: Ask Neon for an impersonated session
  • Step 2: Swap the cookies in a server action
  • Step 3: Detect an impersonated session
  • Step 4: Show a banner and a way out
  • Step 5: Stop impersonating and restore your session
  • Step 6: The bug that resets you to your own account
  • Conclusion
On this page:
  • How user impersonation works in principle
  • Why impersonation needs a service account
  • Step 1: Ask Neon for an impersonated session
  • Step 2: Swap the cookies in a server action
  • Step 3: Detect an impersonated session
Build with Matija logotip

Build with Matija

Complex B2B websites, headless CMS platforms, AI workflows, and internal systems designed and built with Next.js and Payload CMS.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies
  • About Matija

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•Vse pravice pridržane•Politika zasebnosti•Pogoji uporabe
BuildWithMatija
Get In Touch

While replacing the authentication provider on a multi-tenant client project from Stack Auth to Better Auth (hosted via Neon Auth), I needed to verify whether tenant views and edge-case permissions functioned correctly for specific users. In production or staging environments, administrators cannot and should not know customer passwords. Setting up "log in as user" impersonation is a standard operational requirement for multi-tenant SaaS applications: it allows support engineers and superadmins to view the application exactly as a customer sees it, diagnose bugs, and verify onboarding states without credential resets.

The impersonation API call itself worked on the first try. The trouble started right after: I was signed in as the customer on one page, and one click later the app was showing me my own superadmin account again. It took a look at the raw cookies in the browser to find out why, and the cause was a one-line mistake in how Next.js handles cookie expiry that no type checker or test would ever have caught.

This guide walks through the full implementation of user impersonation with Neon Auth (Better Auth under the hood) in Next.js, explains how the impersonation pattern works in principle, and covers the session cookie swapping and Next.js cookie bugs so you can ship impersonation without losing an afternoon to it.

How user impersonation works in principle

Before diving into service accounts and cookie mechanics, it helps to understand what user impersonation actually does at an architectural level.

In a normal authentication flow, a user proves their identity by providing credentials (a password, an OAuth grant, or an email OTP). The auth server verifies those credentials and issues a session token that binds incoming HTTP requests to that user's identity.

Impersonation bypasses password verification through an authorized administrative privilege grant:

  1. Privileged Delegation: An authenticated superadmin triggers an impersonation request targeting a specific userId. Because plain users cannot impersonate others, this request must be authorized by an administrative identity (in Neon Auth, a service account carrying the Neon admin role).
  2. Session Minting with Provenance: The auth engine mints a valid session for the target user without requiring their password. Crucially, the auth engine records provenance—marking the session with metadata such as impersonatedBy: adminUserId. This audit trail proves that actions taken under this session originated from an administrator.
  3. Session Token Parking & Swapping: In the browser, the superadmin's original session token is "parked" into a secure temporary storage cookie (e.g. impersonator_token), while the newly minted target user token takes over the primary session cookie (session_token). To the rest of the application, every standard auth check now resolves to the target user.
  4. Visual State & Reversion Guardrails: The application inspects the session context. When impersonatedBy is present, it renders a persistent global warning banner ("You are viewing as [Customer]") across all screens, disables irreversible or destructive account changes (like changing billing info or triggering welcome emails), and provides an instant "Exit Impersonation" action that revokes the temporary session and restores the parked superadmin token.

Why impersonation needs a service account

Neon Auth exposes an /admin/impersonate-user endpoint, but it only answers to a signed-in session whose user has the Neon role admin. In my app the roles that matter (organisation admin, driver, superadmin) live in my own Prisma User table, and every Neon user keeps the plain user role. If I gave superadmins the Neon admin role, that would be a second, parallel permission system to keep in sync, so I did not.

Instead the server signs in as a dedicated service account that does have the Neon admin role, caches that account's session cookie in memory, and uses it for every admin call (the same pattern detailed in our Neon Managed Better Auth in Next.js on Vercel setup guide and Stack Auth to Neon Auth migration). The impersonation step is one of those calls. Everything else in this guide is about what to do with the result.

Step 1: Ask Neon for an impersonated session

The first piece is a small server-only function that asks Neon to create a session for a target user and hands back the session token. The subtle part is reading the response correctly, because Neon sends more than one Set-Cookie header.

typescript
// File: src/lib/auth/admin.ts
/**
 * Creates a Neon Auth session for `userId` (impersonated by the service
 * account) and returns its session token cookie value.
 */
export const impersonateAuthUser = async (userId: string): Promise<string> => {
  const { response } = await adminRequest("/admin/impersonate-user", {
    method: "POST",
    body: { userId },
  });

  // The response first expires the caller's session cookie, then sets the new
  // one, so take the last non-empty session_token.
  const token = response.headers
    .getSetCookie()
    .map((header) => header.split(";")[0])
    .filter((cookie) => cookie.split("=")[0].endsWith(".session_token"))
    .map((cookie) => cookie.split("=").slice(1).join("="))
    .findLast(Boolean);

  if (!token) {
    throw new AuthAdminError("Sistem za prijavo ni vrnil seje uporabnika.");
  }

  return token;
};

adminRequest is the helper that attaches the service account's cached session cookie and retries once if that session has expired. The response to the impersonation call first expires the service account's own session_token (an empty value) and then sets the new one for the impersonated user. My first version took the first session_token it saw, which is the empty one, and the impersonation silently produced a blank session. Taking the last non-empty value fixes that.

With a token in hand, the next question is where it goes in the browser without destroying your own session.

Step 2: Swap the cookies in a server action

Impersonating means the browser must carry the target's session while you keep your own session safe somewhere, so you can come back. I park the superadmin's token in a separate cookie and put the impersonated token where the SDK expects to find it.

typescript
// File: src/lib/auth/impersonation.ts
// Mirrors the cookie names used by @neondatabase/auth (not exported by the SDK).
const SESSION_TOKEN_COOKIE = "__Secure-neon-auth.session_token";
const SESSION_DATA_COOKIE = "__Secure-neon-auth.local.session_data";
// Holds the superadmin's own session token while impersonating.
const IMPERSONATOR_COOKIE = "__Secure-boneks.impersonator";

// Neon's default impersonation session lifetime.
const IMPERSONATION_MAX_AGE = 60 * 60;

const cookieOptions = {
  path: "/",
  httpOnly: true,
  secure: true,
  sameSite: "lax",
} as const;

The SDK does not export its cookie names, so I mirror them by hand. The impersonation lifetime matches Neon's default of one hour, which also caps how long a forgotten impersonation can stay open. Now the action that starts it:

typescript
// File: src/lib/auth/impersonation.ts
export async function startImpersonationAction(
  userId: string,
): Promise<ActionState<ImpersonationRedirect>> {
  const context = await requireAuthContext();

  if (!isSuperadmin(context) || context.impersonatedBy) {
    return createErrorState("Nimate dovoljenja za to dejanje.");
  }

  const target = await prisma.user.findFirst({
    where: { id: userId, deletedAt: null },
    select: {
      id: true,
      authUserId: true,
      email: true,
      role: true,
      status: true,
      organisationId: true,
    },
  });

  if (!target || !target.authUserId) {
    return createErrorState("Uporabnik ne obstaja.");
  }

  if (target.id === context.userId) {
    return createErrorState("Tega uporabnika ni mogoče prevzeti.");
  }

  if (target.status !== UserStatus.ACTIVE) {
    return createErrorState("Prevzamete lahko samo aktivne uporabnike.");
  }

  const cookieStore = await cookies();
  const ownToken = cookieStore.get(SESSION_TOKEN_COOKIE)?.value;

  if (!ownToken) {
    return createErrorState("Vaše seje ni bilo mogoče najti.");
  }

  let impersonatedToken: string;

  try {
    impersonatedToken = await impersonateAuthUser(target.authUserId);
  } catch (error) {
    console.error("[impersonation] Failed to start", error);
    return createErrorState(
      error instanceof AuthAdminError
        ? error.message
        : "Prevzem uporabnika ni uspel.",
    );
  }

  cookieStore.set(IMPERSONATOR_COOKIE, ownToken, {
    ...cookieOptions,
    maxAge: IMPERSONATION_MAX_AGE,
  });
  cookieStore.set(SESSION_TOKEN_COOKIE, impersonatedToken, {
    ...cookieOptions,
    maxAge: IMPERSONATION_MAX_AGE,
  });
  expireCookie(cookieStore, SESSION_DATA_COOKIE);

  console.info("[impersonation] start", {
    superadminUserId: context.userId,
    targetUserId: target.id,
    targetEmail: target.email,
    targetRole: target.role,
    organisationId: target.organisationId,
  });

  return createSuccessState("update", "Prevzem uporabnika je začel.", {
    redirectTo:
      target.role === UserRole.SUPERADMIN
        ? "/superadmin"
        : target.role === UserRole.DRIVER
          ? "/vozniki"
          : "/admin",
  });
}

The checks at the top do the security work. Only a superadmin may start, and only when not already impersonating, so sessions cannot be chained. The target must exist in your own table, be active, and not be yourself. Impersonating another superadmin is allowed because it grants nothing you do not already have. The write happens only after Neon has issued the new token, so a failed impersonation leaves your session untouched. The last call, expireCookie, removes the SDK's cached session data. That line looks harmless, and it is where the bug lives, so I will come back to it in Step 6.

Notice that the action returns a URL instead of calling redirect(). That is deliberate and also explained in Step 6. Before that, the rest of the app needs to know an impersonation is in progress.

Step 3: Detect an impersonated session

Neon marks an impersonated session with an impersonatedBy field holding the id of the admin behind it. I read it in the same place the app already reads the current user and pass it into the auth context, so every layout and server action can see it.

typescript
// File: src/lib/auth/session.ts
const session = data.session as { impersonatedBy?: string | null } | undefined;

return {
  id: user.id,
  email: user.email,
  name: user.name ?? null,
  impersonatedBy: session?.impersonatedBy ?? null,
};

One more place needs to respect it. My app activates invited users on their first login, which is a state change. An impersonating admin visiting an invite-pending account must not trigger that, so the activation is skipped when impersonatedBy is set:

typescript
// File: src/lib/auth/session.ts
// Impersonation must not change the target's state.
if (
  prismaUser.status === UserStatus.INVITE_PENDING &&
  !authUser.impersonatedBy
) {
  // activate the user
}

With the flag available everywhere, the user interface can make the situation impossible to miss.

Step 4: Show a banner and a way out

While impersonating, every page needs a persistent notice and a button to end it. I render a banner from a server component that returns nothing unless the session is impersonated, and mount it in each layout through the shell's banner slot.

typescript
// File: src/components/impersonation/impersonation-banner.tsx
export async function ImpersonationBanner({ context }: { context: AuthContext }) {
  if (!context.impersonatedBy) {
    return null;
  }

  const user = await prisma.user.findUnique({
    where: { id: context.userId },
    select: { email: true, firstName: true, lastName: true },
  });
  const name =
    [user?.firstName, user?.lastName].filter(Boolean).join(" ") || user?.email;

  return (
    <div className="flex flex-wrap items-center justify-between gap-2 border-b border-amber-300 bg-amber-100 px-4 py-2 text-sm text-amber-950 md:px-6">
      <p className="flex items-center gap-2">
        <UserRound className="h-4 w-4 shrink-0" />
        <span>
          Prijavljeni ste kot <strong>{name}</strong>
          {user?.email && name !== user.email ? ` (${user.email})` : null}
        </span>
      </p>
      <StopImpersonationButton />
    </div>
  );
}

The banner must be mounted in every layout an impersonated user can reach. I mounted it in the admin and driver layouts first and forgot the superadmin layout, which mattered as soon as I allowed impersonating other superadmins: the session looked normal and had no way out. The button that ends the session is a small client component.

typescript
// File: src/components/impersonation/stop-impersonation-button.tsx
const handleStop = () => {
  startTransition(async () => {
    const result = await stopImpersonationAction();

    if (result.success && result.data) {
      // Full page load so no page cached for the impersonated session survives.
      window.location.assign(result.data.redirectTo);
      return;
    }

    toast.error(result.message ?? "Končanje prevzema ni uspelo.");
  });
};

The button calls the stop action, which is the mirror image of the start action.

Step 5: Stop impersonating and restore your session

Stopping has three jobs: revoke the impersonated session on Neon, put the superadmin's own token back, and clear the impersonation cookies.

typescript
// File: src/lib/auth/impersonation.ts
export async function stopImpersonationAction(): Promise<
  ActionState<ImpersonationRedirect>
> {
  const cookieStore = await cookies();
  const ownToken = cookieStore.get(IMPERSONATOR_COOKIE)?.value;

  if (!ownToken) {
    return createErrorState("Ne prevzemate nobenega uporabnika.");
  }

  const context = await getCurrentAuthContext().catch(() => null);
  const impersonatedToken = cookieStore.get(SESSION_TOKEN_COOKIE)?.value;

  // Revoke the impersonated session (best-effort).
  if (impersonatedToken && context?.impersonatedBy) {
    await fetch(`${requireEnv("NEON_AUTH_BASE_URL")}/sign-out`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Cookie: `${SESSION_TOKEN_COOKIE}=${impersonatedToken}`,
        Origin: new URL(requireEnv("NEXT_PUBLIC_ADDRESS")).origin,
      },
      body: "{}",
      cache: "no-store",
    }).catch((error: unknown) => {
      console.error("[impersonation] Failed to revoke session", error);
    });
  }

  cookieStore.set(SESSION_TOKEN_COOKIE, ownToken, {
    ...cookieOptions,
    maxAge: SESSION_MAX_AGE,
  });
  expireCookie(cookieStore, SESSION_DATA_COOKIE);
  expireCookie(cookieStore, IMPERSONATOR_COOKIE);

  console.info("[impersonation] stop", {
    impersonatedUserId: context?.userId ?? null,
    organisationId: context?.organisationId ?? null,
  });

  return createSuccessState("update", "Prevzem uporabnika je končan.", {
    redirectTo: context
      ? `/superadmin/organisations/${context.organisationId}`
      : "/superadmin",
  });
}

If there is no impersonator cookie the action returns an error without touching anything, and that behaviour is useful. It lets the ordinary sign-out button call the stop action first and fall back to a real sign-out, so pressing "Odjava" while impersonating ends the impersonation instead of logging the superadmin out entirely:

typescript
// File: src/lib/auth/sign-out.ts
export const signOutOrStopImpersonation = async (): Promise<boolean> => {
  const stopResult = await stopImpersonationAction().catch(() => null);

  if (stopResult?.success && stopResult.data) {
    window.location.assign(stopResult.data.redirectTo);
    return false;
  }

  await authClient.signOut();
  return true;
};

This is the complete flow, and on paper it is done. In practice it had one bug left, and both the start and stop actions contain the line responsible.

Step 6: The bug that resets you to your own account

After starting an impersonation I landed on the impersonated user's dashboard with the banner showing. Then I clicked a link to another section, and the app was showing my own superadmin account. Reloading did not help. The banner was correct on one page and wrong on the next.

My first theory was the client router cache, because the sidebar prefetches links before you switch accounts. That is a real problem in general, and it is why the actions return a URL and the callers use window.location.assign instead of a server-side redirect(): a full page load discards everything the router cached for the previous session. It was not the cause here, though. The behaviour survived the change.

The answer was in the browser's cookie list. Next to the impersonated session_token there was a __Secure-neon-auth.local.session_data cookie, and decoding its JWT payload showed my own user with impersonatedBy: null. Neon Auth's server SDK caches the session in that signed cookie, and when it is valid and a session token is present, getSession returns the cached payload without asking Neon anything. So any request that carried the stale cache resolved to my own account, even though the token next to it belonged to someone else.

My code was already trying to delete that cache. The problem is how it deleted it:

typescript
// File: src/lib/auth/impersonation.ts (before)
cookieStore.delete(SESSION_DATA_COOKIE);

cookieStore.delete() sends a Set-Cookie header with an expiry in the past, but it does not include the Secure attribute. Browsers refuse to modify a cookie whose name starts with __Secure- unless the header that does it is also marked Secure. The delete was therefore ignored without any error, and the cache stayed. The fix is to expire the cookie with a normal set call that carries the same attributes it was created with:

typescript
// File: src/lib/auth/impersonation.ts
// `cookieStore.delete()` omits `Secure`, which browsers reject for `__Secure-`
// cookies, so the cookie would survive. Expire it explicitly instead.
const expireCookie = (
  cookieStore: Awaited<ReturnType<typeof cookies>>,
  name: string,
) => cookieStore.set(name, "", { ...cookieOptions, maxAge: 0 });

I use this helper for the session cache on both start and stop, and for the impersonator cookie on stop. The stop path had the same flaw in reverse: without it, ending an impersonation could leave the impersonated user's cached session behind and sign the superadmin in as the wrong person.

The lesson is broader than this app. Any cookie with a __Secure- or __Host- prefix has to be written and cleared with Secure set, and Next.js delete() will not do that for you.

Conclusion

I set out to add impersonation to a Next.js app on Neon Auth and hit an odd failure where the app flipped back to my own account. The implementation is a service account that asks Neon for an impersonated session, a server action that parks your own token in a separate cookie and installs the impersonated one, an impersonatedBy flag that drives a banner in every layout, and a stop action that restores your session. The bug that cost me the most time came from the SDK's cached session cookie surviving a cookieStore.delete() that browsers ignore for __Secure- cookies, and it is fixed by expiring such cookies with Secure and maxAge: 0.

You can now build a safe "log in as user" feature on Neon Auth and avoid the stale-session trap. Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

Comments