Server-side login-as for support impersonation
Adds production-capable support impersonation on the server, gated by config, HMAC cookies, per-request revalidation, and platform-event audit trails.
Three server endpoints for support impersonation: candidate listing, session start, and session end.
GET /api/v1/login-as/users · POST /api/v1/login-as · POST /api/v1/end-login-as
Only active DEALOPS_INTERNAL_STAFF actors can start sessions, and both actor and target are revalidated on every request.
Start/end events land in the target customer org, and every event emitted during impersonation gets impersonatedByUserId.
Fixes a broken legacy assertRole guard and removes ungated approval re-attribution via impersonateUserId.
1. Why this exists
Support needs to reproduce what a customer user sees without asking for credentials or relying on local-only dev impersonation.
The old capability was hard-blocked outside localhost. It was not designed for production routing, multi-replica cookies, or audit requirements.
Make impersonation usable in production only when explicitly enabled, while preserving who actually operated the session.
#6507 and is based on this branch, so this should merge first.
2. What changes
New REST surface
/api/v1/login-as/users
Lists active candidate users in the actor’s own organization, excluding the actor. Mounted behind authenticateStytchSession so the actor is always the real staff member.
/api/v1/login-as
Starts a session by exact target userId, scoped to the actor’s organization, emits user.login_as.started, then sets the signed cookie.
/api/v1/end-login-as
Best-effort emits user.login_as.ended, always clears the cookie, and does not depend on Stytch latency to end impersonation.
Before / after auth behavior
- Impersonation was dev-only / localhost-bound.
- Legacy
assertRoleadmitted every authenticated user. setApproval.impersonateUserIdcould re-attribute approvals with no role check.- Dead Stytch sessions could become 500s and page on-call.
- Production can enable login-as with explicit env vars.
- Legacy REST and tRPC auth honor a valid login-as cookie before Stytch.
- Actor must still be active internal staff on every request.
- Dead Stytch sessions return 401 so the client re-authenticates.
Key file map
| Area | Files | Meaningful change |
|---|---|---|
| Login-as core | apps/server/src/lib/auth/loginAs.ts |
Cookie signing, enablement checks, target lookup, actor revalidation, candidate listing, start/end helpers. |
| REST routes | login-as.ts, login-as-users.ts, end-login-as.ts, index.ts |
Mounts the API surface and keeps start/list behind real Stytch auth instead of impersonated auth. |
| Legacy auth | lib/auth/legacy/middleware.ts |
Checks login-as first in authenticateSession, adds authenticateStytchSession, fixes assertRole. |
| tRPC auth | trpc/utils/auth.ts, trpc/createContext.ts, trpc/middleware/impersonationContext.ts, trpc/procedures/authed.ts |
Allows tRPC to resolve as the target user and carry the actor id into downstream event emission. |
| Audit events | PlatformEventEmitter.ts, eventSchemas.ts |
Adds login-as event types and stamps impersonatedByUserId onto events emitted during a session. |
| Cookie utilities | utils/stytch.ts |
Adds clear-cookie options and classifies Stytch session_not_found as an ordinary 401 condition. |
| Tests / env | loginAs.test.ts, middleware.test.ts, turbo.json |
Adds coverage for gating, cookie shape, host-only domain behavior, role enforcement, Stytch 401 handling, and env propagation. |
Security bug fixes bundled in
roles.findIndex(...) !== undefined was always true because misses return -1.
Now assertRole uses roles.includes(req.body.user.role), so protected legacy REST routes actually reject unauthorized users.
setApproval.impersonateUserId let callers re-attribute an approval to any user id, with no role check and no record of the real caller.
The input is removed rather than gated because there are no callers.
session_not_found now maps to 401 instead of 500 in auth paths.
This avoids paging on ordinary expiry/revocation and lets the client return to login.
3. How it works
authenticateStytchSession, not impersonated session auth.user.login_as.started must be written before the cookie is issued.The cookie is base64url(JSON).hmac.
actorUserIdactorStytchMemberIdtargetUserIdorganizationIdexpiresAt
User organization membership can change inside the 2-hour window.
The signed organizationId is re-checked for both actor and target, so a moved/deactivated/demoted account stops resolving immediately.
The impersonation cookie deliberately drops the Stytch domain option.
That avoids invalid loopback domains and prevents overly broad cookies under shared hosts like *.onrender.com.
Enablement rules
| Environment shape | Default | Required to enable | Route behavior when disabled |
|---|---|---|---|
Loopback client URL: localhost, 127.0.0.1, ::1, *.localhost |
Enabled unless explicitly false | No env required; fallback per-process secret is allowed locally. | Start/list return 404. Existing login-as cookies are ignored. |
| Remote/staging/prod URL | Disabled | DEALOPS_LOGIN_AS_ENABLED=true and DEALOPS_LOGIN_AS_SECRET. |
Request attribution
During impersonation, req.body.user and tRPC ctx.user are the target customer user.
This preserves app behavior: permissions, feature flags, org context, and downstream code see what the customer would see.
AsyncLocalStorage carries the actor id after the cookie has been accepted.
PlatformEventEmitter injects impersonatedByUserId into stored payloads for events emitted during the request.
Audit event semantics
- user.login_as.started — strict emit; no audit row means no cookie.
- user.login_as.ended — best-effort emit; failure cannot trap the browser in impersonation.
- Events during session —
userIdremains the impersonated user; payload gainsimpersonatedByUserId. - Audit location — start/end events are filed against the target organization so they surface in the customer audit log.
4. What it doesn’t change
- This PR does not include the client UI; that is in
#6507. - It does not add new database columns or Prisma migrations.
- It does not convert these auth endpoints to tRPC; the new surface is legacy REST under
/api/v1. - It does not let an impersonated admin start another impersonation session; start/list routes use the real Stytch actor.
- It does not share the login-as cookie across subdomains.
- It does not verify the underlying Stytch session when ending login-as; ending must always be fast and local enough to clear the cookie.
5. Risks / rollback / open questions
Full server suite passing, with typecheck and lint clean per PR description.
Production gating, missing secret, secure cookie, host-only domain behavior, exact-id target resolution, org scoping, assertRole, dead Stytch sessions, and event stamping.
No manual run against a real production deployment. The production path is covered by unit tests over env gating.
assertRole can surface existing unauthorized callers as 401s. That is the correct security behavior, but reviewers should scan legacy REST consumers that relied on the old gap.
DEALOPS_LOGIN_AS_ENABLED or set it to false. The routes return 404 and login-as cookies stop resolving. A full code rollback is only needed if the bundled assertRole enforcement causes unexpected production impact.
impersonatedByUserId is injected into event payload JSON rather than stored in a dedicated column. The PR notes a column would be the better long-term home and would require a migration.