Bridge Training
Auth

Impersonation

How an admin acts as another account, from the admin console to the API, and what the package holds of it.

An admin starts an impersonation from the admin console, lands in the teacher app as the target account, and every API call is resolved as that account while the admin's own session stays the bearer. The package owns the client-side state and the URL handoff; the API mints and verifies the token.

Flow

apps/admin                          apps/app                               apps/api
----------                          --------                               --------
POST /impersonation/start  ───────────────────────────────────────────►  mint token (HMAC, 1 h)
  { targetUserId, reason }                                                 log the start
◄── { token, expiresAt }
buildImpersonationHandoffUrl()
open app URL#fragment  ──────────►  captureImpersonationHandoff()
                                    fragment → sessionStorage, URL scrubbed
                                    useImpersonationHandoff()
                                      admin signed in? → actions.start()
                                    every request:
                                      Authorization: Bearer <admin JWT>
                                      X-BT-Impersonation: <token>  ───►  verify token, actor must match bearer
                                                                        userId = target, actorId = admin
                                    stop → POST /impersonation/stop ──►  log the stop
                                           actions.stop()

Three invariants:

  • The token travels in the URL fragment, never in a query string or a request to the app's server.
  • The token is bound to the admin session that started it: the API refuses it with any other bearer.
  • The package never calls the API. Minting, logging and stopping are HTTP calls the app and the admin make themselves.

Handoff helpers

Import from @workspace/auth/impersonation-handoff. Pure functions, usable on the server side of the admin and in the browser.

buildImpersonationHandoffUrl

import { buildImpersonationHandoffUrl } from '@workspace/auth/impersonation-handoff';
import { requireAbsoluteUrl } from '@workspace/shared/url';

const url = buildImpersonationHandoffUrl(requireAbsoluteUrl(env.APP_BASE_URL), {
  token: started.token,
  subjectId: targetUserId,
  actorId: adminUserId,
  expiresAt: started.expiresAt,
});
// https://app.example/#impersonation_token=…&impersonation_subject=…&impersonation_actor=…&impersonation_expires_at=…

hasImpersonationHandoff and parseImpersonationHandoff

hasImpersonationHandoff(hash) is true as soon as the fragment carries a token, valid or not, so the caller can scrub it. parseImpersonationHandoff(hash) returns an ImpersonationHandoff or null when any field is missing or malformed.

type ImpersonationHandoff = {
  token: string;
  subjectId: string;
  actorId: string;
  expiresAt: string;   // ISO datetime
};

impersonationHandoffSchema is the zod schema behind it, for a consumer that stores the handoff and must re-validate it.

What the app does with it

The app captures the fragment before React mounts, then a receiver component resolves it once the store knows who is signed in:

SituationOutcome
expiresAt in the pastError toast, handoff dropped
Nobody signed inInfo toast asking to sign in, handoff kept for after sign-in
Signed-in user is the actorIdactions.start(token, subjectId)
Signed-in user is someone elseError toast, handoff dropped

Client state

The store holds { token, subjectId, actorId, startedAt } while an impersonation runs.

  • Under Clerk it is persisted in sessionStorage under bridge_impersonation, so a reload keeps the effective identity but a new tab starts clean.
  • Under the simulated provider it stays in memory.
  • When the signed-in user changes and no longer matches actorId, the store drops the impersonation.

Read it with useImpersonation, drive it with useImpersonationActions. useAuthPort().getImpersonationToken() exposes the token for the HTTP client; the app forwards it as the X-BT-Impersonation header.

Effective identity

While impersonating:

ReadReturns
auth.getUserId()The subject
auth.getActorId()The admin
useImpersonation().actorLabelThe admin's name, else e-mail, when the provider knows it
session.getToken()The admin's bearer, unchanged

Everything that fetches data keys on getUserId(), so queries refetch when the impersonation starts or stops. The app shows a banner on every route from useImpersonation(), with a stop button that records the stop with the API, then calls actions.stop() whether or not the record succeeded.

Rejections

When the API refuses the token (expired, signature, actor mismatch, or no signing secret configured), the HTTP client reports it and the app stops the impersonation locally. The admin lands back on their own account with a toast.