Signing in as a user
Open your app as one of its users from the console, read-only by default, for 5 to 60 minutes, with the start and an early end audited.
When a customer writes in to say the button is not there, the quickest way to find out why is to look at their screen. Sign in as this user, on the user's page in the console, opens your app in a new tab signed in as them. The session is read-only unless the owner says otherwise, it ends on its own, and its start goes into the security audit log.
Step by step, with screenshots: Sign in as a user.
Before you start
Sign in to the console with a role that can create users and read auth settings. Your client needs a redirect URL for the session to arrive at. An API token cannot start one: it has nobody to hold the tab.
Starting one
The dialog asks for four things:
| Field | What it does |
|---|---|
| App | The client to sign in to. An agent client is refused |
| Open at | One of that client's redirect URLs |
| Minutes | How long the session lasts: 5 to 60, and 15 unless you change it |
| Read-only | On by default. Only the owner role can turn it off |
Your app needs nothing new for this. The console mints an ordinary sign-in code for that redirect URL, and your backend exchanges it at POST /api/v1/auth/token exactly as it does after a real sign-in. The session it makes:
- ends when its minutes run out, however long your sessions normally last
- is never a recent sign-in, so it cannot confirm it's you or pass a change that needs one
- does not count the person as an active user
A blocked user cannot be signed in as. Unblock them first.
Read-only
A read-only session reads and cannot change anything through BuildBase. Every write to the session API, /api/v1/public with any method but GET, HEAD and OPTIONS, answers 403 read_only_session. Signing out still works, for that session only: POST /api/v1/public/logout with { all: true }, which signOut({ everywhere: true }) sends, ends the read-only session and leaves the person's own sessions alone. The SDK's screens show that refusal's sentence, and isReadOnlySession(error) recognises it in your own code.
Your own data is your backend's call. BuildBase cannot see your database, so read-only stops at BuildBase. The profile tells your backend who is looking: GET /api/v1/public/profile, and the user in the code exchange's answer, carry impersonation: { actorName, readOnly, expiresAt } for as long as the session lasts. Refuse your own writes while readOnly is true.
With read-only off, everything the owner does in that tab is done as the person. That is why only the owner role can choose it.
Except how they sign in. Their email address, phone number, username, password, second step, passkeys and linked accounts stay theirs in every sign-in-as session, read-only or not: each of those changes answers 403 impersonation_sign_in_locked. A wrong current password typed in that tab does not count toward the person's own sign-in lockout either.
The banner
For as long as the session lasts, the SDK pins a bar to the bottom of the window: "You are viewing as Ada. Grace signed in as this person, read-only until 3:15 PM.", with Exit, which signs that tab out. SaaSOSProvider renders it already. To put it in your own layout instead:
import type { ReactNode } from 'react';
import { ImpersonationBanner, SaaSOSProvider } from '@buildbase/sdk/react';
export function Providers({ children }: { children: ReactNode }) {
return (
<SaaSOSProvider
// ...serverUrl, orgId and auth, as before
ui={{ impersonationBanner: false }}
>
<ImpersonationBanner fixed={false} />
{children}
</SaaSOSProvider>
);
}useSaaSAuth().session.impersonation holds the same three fields.
Ending one early
End sign-in-as sessions, on the same user page, signs out every session anyone opened as that person from the console, wherever it is open. The person's own sessions are not touched. Each early end from the console is written to the security audit log too. Exit on the banner, and a session running out, are not.
Audit
| Row | Written when |
|---|---|
impersonation.started | A session starts. It records the client, whether it is read-only, and when it ends |
impersonation.ended | Sessions are ended early from the console. It records how many |
The API
| Route | Body | Answers |
|---|---|---|
POST /api/users/:id/impersonate | { clientId, redirectUrl, minutes?, readOnly? } | { url, sessionExpiresAt, readOnly }. Open url |
DELETE /api/users/:id/impersonate | none | { ended }, the number of sessions ended |
| Refusal | When |
|---|---|
403 console_only | The request came with an API token |
403 owner_required | readOnly: false from someone without the owner role |
400 self | You tried to sign in as yourself |
400 invalid_client | No such client, or an agent client |
400 invalid_redirect_url | redirectUrl is not one of the client's redirect URLs |
409 user_blocked | The user is blocked |
Next steps
- Custom claims - the other per-user setting on the same page.
- Deleting accounts - delete a user from the console.
- Sessions & Tokens - how a session is stored and ended.