Authentication
This page shows how people sign in to OpenBot and how programs call the API. A person signs in once in a browser. Then the person creates user tokens for scripts and services.
There are two ways to call the business routes under /v1:
| Way | Who uses it | What you send |
|---|---|---|
| User token | Scripts, backend services, bots | Authorization: Bearer <token> |
| Session cookie | A browser client on the OpenBot origin | The __Host-openbot-session cookie |
Both ways need a permission for each call. See Permissions.
Sign in
OpenBot does not keep passwords. It uses OpenID Connect providers. OpenID Connect (OIDC) is a standard way to let another service check who a person is. There are two kinds of providers:
- The platform provider signs in people for the whole platform. A platform admin sets it. See Set up the platform provider.
- An SSO connection is the provider of one organization. It signs in people with an email in the verified domains of that organization. See SSO and domains.
To start a sign-in, your client sends this request from the OpenBot origin:
curl -X POST https://api.example.com/auth/sign-in \ -H "Origin: https://api.example.com" \ -H "Content-Type: application/json" \{ "url": "https://id.example.com/authorize?..." }Both fields are optional. OpenBot uses the domain of email to pick the provider. When the domain has an SSO connection, the url goes to it. Otherwise it goes to the platform provider. When there is neither, OpenBot returns 503 with SignInUnavailable.
Send the browser to that url. The answer also sets the login state cookie. After the person signs in, the provider sends the browser back to OpenBot. OpenBot sets the session cookie and sends the browser to returnTo. See Return paths.
If the sign-in fails, the browser goes to /auth/error?error=<code> and gets no cookie.
Better Auth routes
OpenBot uses Better Auth for sign-in. Better Auth serves its routes under /auth/*, as its Effect guide shows. These routes are not in the API reference. The Better Auth docs describe them.
You can still use them directly. The platform provider has the provider ID oidc and the callback /auth/callback/oidc. An SSO connection uses its own ID as the provider ID, and its callback is /auth/callback/<connectionId>.
curl -X POST https://api.example.com/auth/sign-in/social \ -H "Origin: https://api.example.com" \ -H "Content-Type: application/json" \ -d '{ "provider": "oidc", "callbackURL": "https://api.example.com/settings/keys" }'The callbackURL must be on the OpenBot origin. If it is not, Better Auth returns 403.
Set up the platform provider
Register OpenBot as a client at your provider. Use <publicOrigin>/auth/callback/oidc as the redirect URI. OpenBot asks for the scopes openid profile email.
Then give the provider to OpenBot. On a new platform, the setup program does this with --oidc-issuer, --oidc-client-id and --oidc-client-secret. Later, a platform admin changes it with PATCH /v1/platform/settings. See Platform.
| Setting | Value |
|---|---|
identityProvider.issuer |
The issuer URL, for example https://id.example.com. HTTPS, no trailing slash. |
identityProvider.clientId |
The client ID from the provider. |
identityProvider.clientSecret |
The client secret. Leave it out for a public client. |
publicOrigin |
The public HTTPS origin of OpenBot, for example https://api.example.com. |
Without a client secret, OpenBot signs in as a public client with PKCE. PKCE is a check that protects the sign-in code. A platform can have no provider at all. Then people sign in only through SSO connections.
Development account
The development server (devenv shell -- pnpm dev) uses one local test account and no provider. The account is [email protected], and it is a platform admin.
Only the development entry point offers this. The production program never does. See Configuration.
This command signs in and saves the cookie in a file:
curl -c cookies.txt -X POST https://localhost:8443/auth/development/sign-in \ -H "Origin: https://localhost:8443" \ -H "Content-Type: application/json" \ -d '{}'Read and end the sign-in
GET /auth/session returns the signed-in account and its organizations. It needs the session cookie.
{ "userId": "u_123", "profile": { "name": "Ada", "emailVerified": true }, "memberships": [ { "organizationId": "org_8f3a", "name": "Example", "status": "active", "roles": ["member"] } ], "platformAdmin": false, "expiresAt": "2026-11-19T10:00:00.000Z"}| Field | What it is |
|---|---|
memberships |
The organizations of the account, with its status and roles in each one. |
platformAdmin |
true when the account is a platform admin. Then it can use /v1/platform. See Platform. |
POST /auth/sign-out ends the sign-in. It is a Better Auth route. Send a JSON body ({}) and the Origin header (see below). It returns 200.
Membership
One platform has many organizations. One account can be a member of many of them. When an account signs in, OpenBot checks where it belongs:
- It joins the organization that verified the domain of its verified email, when that organization allows
joinByDomain. - It joins the organization of its SSO connection, if it signed in through one.
- If it still has no organization, and the platform lets anyone create one, it gets a new organization named “Personal”. It is the Super Administrator there.
- Otherwise it has no organization. Organization routes then answer
403withMembershipRequired.
OpenBot also does these checks on each request with the session cookie. See Organization.
A suspended member gets 403 with MembershipRequired when they use the cookie. Their tokens stop working at once and get 401 with AuthenticationRequired.
Choose an organization
A request with the session cookie must say which organization it acts in. Send the ID in the OpenBot-Organization header:
curl -b cookies.txt \ -H "OpenBot-Organization: org_8f3a" \ https://api.example.com/v1/sessions- Without the header, OpenBot uses your only active organization.
- If you have more than one active organization and send no header, OpenBot returns
400withOrganizationRequired.
A user token belongs to one organization. It ignores the header.
The session cookie
After sign-in, the browser holds the cookie __Host-openbot-session. It is Secure, HttpOnly and SameSite=Lax.
- A sign-in lasts 30 days.
- OpenBot renews it after one day of use.
The Origin rule
A cookie goes with every browser request, even from other sites. So OpenBot checks where a write comes from.
When you use the cookie, every POST, PUT, PATCH and DELETE request must send an Origin header.
The header must be exactly equal to publicOrigin in the platform settings. If it is not, OpenBot returns 403 with ForbiddenOrigin.
curl -b cookies.txt \ -H "Origin: https://localhost:8443" \ -H "Content-Type: application/json" \ -d '{"title":"Notes"}' \ https://localhost:8443/v1/sessionsRequests with a user token do not need the Origin header.
User tokens
A user token lets a program act for you. A token can never do more than you can do. It can also have its own policies, so it does less. See Permissions.
You can manage tokens with the session cookie, or with a token that has the tokens.write permission. A token made without policies cannot manage tokens.
A token belongs to one organization: the organization of the request that made it. With the cookie, send the OpenBot-Organization header to choose it. See Choose an organization.
See the full shapes in the User tokens reference.
Create a token
curl -b cookies.txt \ -H "Origin: https://api.example.com" \ -H "Content-Type: application/json" \ -d '{"name":"CI bot","expiresInDays":90}' \ https://api.example.com/v1/user/tokens| Field | Rules |
|---|---|
name |
Required. 1 to 80 characters after trimming spaces. |
expiresInDays |
Optional. A whole number from 1 to 365. Leave it out for a token that works until you revoke it. |
policies |
Optional. Policies that limit the token. Leave it out to allow everything that you can do, except tokens.read and tokens.write. |
OpenBot returns 201:
{ "token": { "id": "5f0c2a8e-7d1b-4c3e-9a55-2b6f1e8d4c10", "name": "CI bot", "prefix": "obut_Xr4pQ2aL", "policies": [ { "effect": "allow", "permissionGroups": [ "responses.run", "sessions.read", "sessions.write" ], "resources": ["openbot.org.{org}"] } ], "createdAt": "2026-10-20T09:00:00.000Z", "expiresAt": "2027-01-18T09:00:00.000Z", "lastUsedAt": null }, "secret": "obut_Xr4pQ2aL..."}Every secret starts with obut_, so a leaked token is easy to spot. The prefix is the first 13 characters. Use it to tell your tokens apart.
List tokens
curl -b cookies.txt https://api.example.com/v1/user/tokensThe answer is { "items": [ ... ] }, oldest first. The list never contains secrets.
lastUsedAt changes at most once a minute. Expired tokens stay in the list until you revoke them.
Revoke a token
curl -X DELETE -b cookies.txt \ -H "Origin: https://api.example.com" \ https://api.example.com/v1/user/tokens/5f0c2a8e-7d1b-4c3e-9a55-2b6f1e8d4c10OpenBot returns 204 and deletes the token. The token stops working at once.
Check a token
GET /v1/tokens/verify shows who the caller is. For a token, it also shows the token and its policies. Every caller can use it.
{ "caller": { "kind": "user_token", "userId": "u_123", "organizationId": "org_8f3a", "tokenId": "5f0c2a8e-7d1b-4c3e-9a55-2b6f1e8d4c10" }, "token": { "id": "5f0c2a8e-7d1b-4c3e-9a55-2b6f1e8d4c10", "name": "CI bot" }}With the session cookie, caller.kind is member and token is null.
Limits and errors
| Status | Error | When |
|---|---|---|
400 |
TokenPolicyInvalid |
A policy names a resource outside your organization or openbot.platform. |
401 |
AuthenticationRequired |
No valid cookie or token. |
403 |
PermissionDenied |
The caller does not have tokens.read or tokens.write. |
403 |
ForbiddenOrigin |
A cookie write without the exact Origin header. |
404 |
TokenNotFound |
You have no token with this ID. |
409 |
TokenLimitReached |
You already have 25 tokens. The body has "limit": 25. |
503 |
TokenStorageUnavailable |
OpenBot cannot reach its database. |
A member can hold 25 tokens at a time. Expired tokens count too, so revoke old tokens to make room.
Use a token
Send the token in the Authorization header on any /v1 route:
curl -H "Authorization: Bearer $OPENBOT_TOKEN" \ https://api.example.com/v1/sessionsA revoked, expired or unknown token gets 401 with AuthenticationRequired. If OpenBot cannot check the token, it returns 503 with AuthenticationUnavailable.
Your own space
Inside the organization, each member has their own Sessions, Apps and connections. Your tokens and your cookie see only your own.
If you ask for a Session of another member, OpenBot answers 404, as if it does not exist.
Return paths
Some flows send the browser back to your client after a round trip. You choose the place with returnTo.
A return path must be a path on the same origin. It must start with one /. It cannot point to /auth/*, /v1/* or /development/*, because OpenBot owns those paths. The default is /.
OpenBot has no pages, so your client must serve these paths. Two flows add a query value:
- App authorization. When an App sign-in comes back and the person is signed in, OpenBot finishes the flow. The browser goes to the return path with
?authorization=<flowId>. Your client can read the result withGET /v1/app-authorizations/<flowId>. If the person is not signed in, the browser goes to/?authorization=<flowId>. Your client signs the person in withPOST /auth/sign-in(see Sign in), with this address asreturnTo. Then it callsPOST /v1/app-authorizations/<flowId>/completeto finish the flow. - Developer tests. A developer test authorization returns to
/?developerTest=<testId>.