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:
| Situation | Outcome |
|---|---|
expiresAt in the past | Error toast, handoff dropped |
| Nobody signed in | Info toast asking to sign in, handoff kept for after sign-in |
Signed-in user is the actorId | actions.start(token, subjectId) |
| Signed-in user is someone else | Error toast, handoff dropped |
Client state
The store holds { token, subjectId, actorId, startedAt } while an impersonation runs.
- Under Clerk it is persisted in
sessionStorageunderbridge_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:
| Read | Returns |
|---|---|
auth.getUserId() | The subject |
auth.getActorId() | The admin |
useImpersonation().actorLabel | The 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.