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…
·Updated on:··
⚡ 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.
I was adding a "log in as this user" button to a multi-tenant Next.js app so a superadmin could see exactly what a customer sees. The impersonation 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 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 that no type checker or test would ever have caught. This guide walks through the full implementation with Neon Auth (Better Auth under the hood), including that bug, so you can ship impersonation without losing an afternoon to it.
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.
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.
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:
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.
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.
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.
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:
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:
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.constexpireCookie = (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 .
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
// 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
async
userId
string
Promise
string
const
await
adminRequest
"/admin/impersonate-user"
method
"POST"
body
// The response first expires the caller's session cookie, then sets the new
// one, so take the last non-empty session_token.
const
headers
getSetCookie
map
(header) =>
split
";"
0
filter
(cookie) =>
split
"="
0
endsWith
".session_token"
map
(cookie) =>
split
"="
slice
1
join
"="
findLast
Boolean
if
throw
new
AuthAdminError
"Sistem za prijavo ni vrnil seje uporabnika."
return
// 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.