Skip to content

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:

Terminal window
curl -X POST https://api.example.com/auth/sign-in \
-H "Origin: https://api.example.com" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","returnTo":"/settings/keys"}'
{ "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>.

Terminal window
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:

Terminal window
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",
"email": "[email protected]",
"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:

  1. It joins the organization that verified the domain of its verified email, when that organization allows joinByDomain.
  2. It joins the organization of its SSO connection, if it signed in through one.
  3. 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.
  4. Otherwise it has no organization. Organization routes then answer 403 with MembershipRequired.

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:

Terminal window
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 400 with OrganizationRequired.

A user token belongs to one organization. It ignores the header.


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.

Terminal window
curl -b cookies.txt \
-H "Origin: https://localhost:8443" \
-H "Content-Type: application/json" \
-d '{"title":"Notes"}' \
https://localhost:8443/v1/sessions

Requests 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

Terminal window
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

Terminal window
curl -b cookies.txt https://api.example.com/v1/user/tokens

The 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

Terminal window
curl -X DELETE -b cookies.txt \
-H "Origin: https://api.example.com" \
https://api.example.com/v1/user/tokens/5f0c2a8e-7d1b-4c3e-9a55-2b6f1e8d4c10

OpenBot 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:

Terminal window
curl -H "Authorization: Bearer $OPENBOT_TOKEN" \
https://api.example.com/v1/sessions

A 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 with GET /v1/app-authorizations/<flowId>. If the person is not signed in, the browser goes to /?authorization=<flowId>. Your client signs the person in with POST /auth/sign-in (see Sign in), with this address as returnTo. Then it calls POST /v1/app-authorizations/<flowId>/complete to finish the flow.
  • Developer tests. A developer test authorization returns to /?developerTest=<testId>.