# API authentication

> Personal API tokens, organization app tokens for CI, and Bearer authentication.

Integrations authenticate with an **organization-scoped token** sent as a Bearer credential: a personal **API token** for scripts and API clients, or an organization **[app token](#app-tokens)** for reporting from CI. The Probara web app continues to use **session cookies** plus `X-Organization-Id` for org switching.

## API tokens (available)

An API token belongs to the user who created it, in one organization. It acts with that user's current permissions, and it is revoked when the user leaves the organization. Use it for scripts and API clients; to report test results from CI, prefer an [app token](#app-tokens), which belongs to the organization.

Creating and revoking a token requires the **Manage API tokens** permission (`api-tokens.manage`), held by owners, admins and members. A **viewer** can read the token table and nothing more: creating or revoking returns `403 forbidden`, and the screen offers no create or revoke control. While the organization requires two-factor authentication, creating a token also needs your account to have a confirmed authenticator app; otherwise the request returns `403 organization_two_factor_required`. Tokens created earlier keep working.

1. Sign in to Probara and open **API tokens** from the avatar menu (`/user/api-tokens`).
2. Click **New token** at the top of the screen to open the create dialog, enter a **name**, and click **Create token** — the token is created for the organization currently in focus.
3. Copy the secret from the one-time panel immediately — it is shown **once** and cannot be retrieved later.
4. Review **Active tokens** in the table (Created, Last used or **Never**) and revoke with the trash icon when rotating credentials.

Token format: `probara_` followed by base64url-encoded random bytes. Store tokens in your CI secret store, not in the repository.

## Bearer requests

Send the raw token on every protected `/api/v1/*` call (except session-only auth and token management routes):

```http
Authorization: Bearer probara_xxxxxxxx
```

With a valid API token you **do not** need `X-Organization-Id`. The Worker resolves organization and membership from the token row.

Example:

```bash
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects"
```

## App tokens

An **app token** belongs to the organization, not to a person. It is bound to one app of the **Integrations** catalog — **JUnit XML**, for the [Probara CLI](https://docs.probara.net/en/developers/cli/), **Playwright**, for the [Probara Playwright reporter](https://docs.probara.net/en/guides/integrations/playwright/), **Jest**, for the [Probara Jest reporter](https://docs.probara.net/en/guides/integrations/jest/), **Cypress**, for the [Probara Cypress reporter](https://docs.probara.net/en/guides/integrations/cypress/), **Vitest**, for the [Probara Vitest reporter](https://docs.probara.net/en/guides/integrations/vitest/), or **Cucumber**, for the [Probara Cucumber reporter](https://docs.probara.net/en/guides/integrations/cucumber/) and the CLI's [Gherkin sync](https://docs.probara.net/en/guides/syncing-feature-files-from-git/) — and it is the credential to use in CI:

|                         | API token                                                | App token                                                      |
| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
| Belongs to              | A user, in one organization                              | The organization, bound to one app                             |
| Managed from            | **Avatar menu → API tokens**                             | The app's card in **Workspace → Integrations**                 |
| Create and revoke       | `api-tokens.manage` (held by owners, admins and members) | `integrations.manage` (held by owners and admins)              |
| Secret                  | `probara_…`                                              | `probara_app_…`                                                |
| Can call                | Everything its user can, except starting a plan trial    | Only the six calls a reporter makes ([below](#allowed-calls))  |
| When its creator leaves | Revoked                                                  | Keeps working                                                  |
| Shown in activity as    | You, marked “From an API token”                          | The app and the token name, such as **JUnit XML · nightly-ci** |
| Plan                    | Any                                                      | Paid plans only ([below](#plan))                               |

An app token uses no seat. Because it survives its creator, rotating it is up to you: revoke it and create a new one when it may have leaked, or when someone who could read the secret leaves your team.

### Create and revoke

1. As an owner or admin, open **Workspace → Integrations** and select the card of the app that will use it: **JUnit XML**, **Playwright**, **Jest**, **Cypress**, **Vitest** or **Cucumber**.
2. Open the **Access tokens** tab and select **Create an app token**.
3. Enter a **Token name** — name it after what uses it, such as the repository or the pipeline — and select **Create token**.
4. Copy the secret from **Copy your new token**. It starts with `probara_app_` and is shown **once**: store it as the CI secret `PROBARA_API_TOKEN` right away.

The **Access tokens** tab lists the active tokens with who created each one, when, and when it was last used. **Revoke** (after a confirmation) stops it at once: the next request with it answers `401 unauthorized`. Revoked tokens keep their name and app in the activity they left behind.

The card uses these routes. Like the rest of `/api/v1/orgs/{orgUlid}/integrations`, they accept a session cookie only; a Bearer credential is refused.

| Method   | Path                                                                | Behavior                                                                                                                                                                         |
| -------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/api/v1/orgs/{orgUlid}/integrations/apps/{app}/tokens`             | `{ items: [{ ulid, app, name, createdAt, lastUsedAt, createdBy }] }` — active tokens, never the secret. `createdBy` is `{ ulid, name }`, or `null` once that account was deleted |
| `POST`   | `/api/v1/orgs/{orgUlid}/integrations/apps/{app}/tokens`             | Body `{ name }` (1–100 characters). `201 { token, appToken }`: `token` is the secret, returned only here                                                                         |
| `DELETE` | `/api/v1/orgs/{orgUlid}/integrations/apps/{app}/tokens/{tokenUlid}` | Revokes the token. `204`; `404` when it is already revoked or belongs to another organization or app                                                                             |

`{app}` is `junit`, `playwright`, `jest`, `cypress`, `vitest` or `cucumber`; any other value answers `404 not_found`. Each app lists, creates and revokes only its own tokens. All six need the **Manage integrations** permission (`integrations.manage`, held by owners and admins), otherwise `403 forbidden`. Creating also needs a paid plan and is limited to 10 per user per hour.

### Allowed calls

An app token may make exactly the calls the CLI and the framework reporters make:

| Method  | Path                                                            | Used for                                                                                             |
| ------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `POST`  | `/api/v1/projects/{projectId}/runs`                             | Create a run — only with `automated: true`                                                           |
| `POST`  | `/api/v1/projects/{projectId}/reports`                          | Send a [report](https://docs.probara.net/en/developers/api-reference/#reports)                                               |
| `POST`  | `/api/v1/projects/{projectId}/gherkin-sync`                     | Sync the scenarios of [Gherkin feature files](https://docs.probara.net/en/guides/syncing-feature-files-from-git/)            |
| `POST`  | `/api/v1/runs/{runUlid}/results/{resultUlid}/attachments:stage` | Stage [result attachments](https://docs.probara.net/en/developers/api/result-attachments/)                                   |
| `PATCH` | `/api/v1/runs/{runUlid}/results/{resultUlid}/attachments`       | Commit them — add only                                                                               |
| `POST`  | `/api/v1/runs/{runUlid}/close`                                  | Close the run                                                                                        |
| `GET`   | `/api/v1/runs/{runUlid}/case-keys`                              | Read a run's [case keys](https://docs.probara.net/en/developers/api/run-cases/#list-a-runs-case-keys), to run only its cases |

Every other method and path — every other read included, and the [MCP endpoint](https://docs.probara.net/en/developers/mcp/) `/mcp` — answers `403 forbidden` before anything runs. Within those calls:

- Creating a run without `automated: true` answers `403 forbidden`: an app token only creates automated runs.
- A commit may only **add** attachments. A body that leaves out an attachment the result already has answers `403 forbidden` and changes nothing, so send the committed files back as `{ ulid, position }` alongside the new ones.
- It acts with the run execution, test case write and result execution permissions, and nothing more. It is not exempt from a project's assignee result lock (`403 run_case_assignee_locked`), and, like an API token, it is not evaluated against project access lists.
- Its requests count toward the organization's API rate limit, each result it records counts toward the monthly API result limit, and `Idempotency-Key` works as for any other credential.

### Plan

Reporting from CI is included in the paid plans. On an organization without a paid plan:

- every request with an app token answers `403 forbidden`, and creating one is refused the same way;
- `POST /api/v1/projects/{projectId}/reports`, and `POST /api/v1/projects/{projectId}/runs` with `automated: true`, answer `403 forbidden` for every credential — API tokens and sessions included. Creating a manual run stays available;
- listing and revoking app tokens still work.

Moving to the Free plan does not revoke app tokens: Probara keeps them and refuses them, and they work again as soon as the organization is back on a paid plan.

### Attribution

What an app token does is attributed to the token, and the web app shows the app's logo and name beside the token name. In API responses, the `api_token` form of a run's, a test plan's or a test case's `author`, of a test case's `updatedBy` and of an audit event's `actor`, and a result's `executedByApiToken`, carry `app` — `"junit"`, `"playwright"`, `"jest"`, `"cypress"`, `"vitest"` or `"cucumber"` for an app token, `null` for an API token — and organization attachment items carry `uploaderApp` the same way.

## OAuth 2.1 (MCP clients)

The `/mcp` endpoint additionally accepts an **OAuth 2.1** access token as a Bearer credential — this is the flow interactive MCP clients (Claude.ai, Claude Code, Cursor, ChatGPT) use instead of copy/pasting an API token. See the [MCP connection guides](https://docs.probara.net/en/developers/mcp/) for per-client setup steps.

### Discovery

Any client can discover the authorization server and this resource's requirements without prior configuration:

| Metadata document                        | Path                                          |
| ---------------------------------------- | --------------------------------------------- |
| Authorization server metadata (RFC 8414) | `GET /.well-known/oauth-authorization-server` |
| Protected-resource metadata (RFC 9728)   | `GET /.well-known/oauth-protected-resource`   |

Both documents advertise **PKCE `S256` only** (no `plain`) and the exact scopes below. The authorization server metadata also sets `authorization_response_iss_parameter_supported` and `client_id_metadata_document_supported` to `true`.

### Dynamic Client Registration (DCR)

Clients register themselves before the first authorize request — no manual "create an OAuth app" step:

```http
POST /oauth/register
Content-Type: application/json

{ "redirect_uris": ["https://client.example.com/callback"] }
```

Returns a `client_id` and the registered `redirect_uris`. Registration is rate-limited and capped. A refused registration returns `429` with a `Retry-After` header, in seconds. Client expiry is two-tier: a client that never completes an authorization expires 24 hours after registration; a client that has authorized at least once expires 60 days after its last token activity (exchange or refresh).

### Client ID Metadata Documents (CIMD)

A client can also identify itself with an `https` URL it controls, instead of registering. Host a JSON document at that URL and use the URL as the `client_id`:

```json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Example MCP Client",
  "redirect_uris": ["http://127.0.0.1:3000/callback"],
  "token_endpoint_auth_method": "none"
}
```

The server fetches the document when it first sees the URL, and again once its cache lapses. The cache lasts as long as the document's `Cache-Control: max-age` (or `Expires`), between 5 minutes and 24 hours. The requirements:

- The `client_id` in the document equals its URL exactly. The URL uses `https` and has a path. It has no query string, fragment, credentials, port, IP address, or `localhost` host, and its host name has no trailing dot.
- `client_name` and `redirect_uris` are required. A `redirect_uri` in the authorize request must appear in the list.
- `token_endpoint_auth_method` is `none` or absent. Client secrets are not accepted.
- The document is `application/json`, at most 5 KiB, and must not redirect.

Refused requests return `429` with a `Retry-After` header, in seconds. A signed-in user can add 10 new client documents per day, every user together can add 20 per registrable domain per day, and one user can fetch unknown URLs at most 10 times per host and 30 times in total per hour. A registrable domain is `example.com` with its subdomains. Under a shared-hosting domain such as `github.io`, each site (`alice.github.io`) is a registrable domain of its own.

When the host cannot be reached while the server fetches or refreshes a document (a timeout, a server error, or a DNS failure), a client that has already been authorized keeps working from its last valid copy for up to 24 hours after its cache lapsed. A client that has never been authorized, or whose copy is older than that, gets `503` with `error` set to `temporarily_unavailable` and a `Retry-After` header. A document that is definitively invalid, for example one that redirects or names another `client_id`, is refused with `400` at once, and no copy is used in its place.

Expiry follows the two tiers described for registered clients above. An expired document client is refused until the hourly cleanup removes it. Then the same URL is admitted as a new client, and its users must authorize it again. The consent screen shows the host that served the document, so users can see where the application came from. Registration through `/oauth/register` keeps working for clients that prefer it.

### Authorize + consent

1. The client redirects the user to `GET /oauth/authorize` with a PKCE `code_challenge` (`S256`), the exact registered `redirect_uri`, the `resource` set to the MCP server's URL (`https://app.probara.net/mcp`, RFC 8707), and an **optional** `scope` — see [Scopes](#scopes) below for what happens when it is omitted or unrecognized. An omitted `resource` is read as that URL. Any other resource, or several, is refused with `invalid_target`. Spellings of the same URL (uppercase scheme or host, trailing slash) are accepted.
2. Probara requires an active session; an unauthenticated visitor is sent to log in first, then returned to consent — no code is ever minted without a session.
3. The user picks **which organization** to grant access to and approves or denies on the consent screen. Approval mints an authorization code bound to that organization, the negotiated (and possibly role-downgraded) scope, and the PKCE challenge. While the organization requires two-factor authentication, approval also needs the user's account to have a confirmed authenticator app; otherwise consent returns `403 organization_two_factor_required` and no code is minted.
4. The client exchanges the code for tokens:

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...&client_id=...&resource=https%3A%2F%2Fapp.probara.net%2Fmcp
```

Send the same `resource` on this request. If it names another resource, the response is `400` with `error: invalid_target`, and the code stays unused.

Once `client_id` and `redirect_uri` are validated, any further authorize error (unsupported `response_type`, invalid PKCE, or a `resource` that is not this server's MCP URL) is delivered as a `302` redirect to the registered `redirect_uri` with `error`, `error_description`, and the echoed `state` in the query — per RFC 6749 §4.1.2.1 — not a raw JSON error. Every redirect to the registered `redirect_uri`, success or error, also carries `iss`, set to the `issuer` in the metadata document (RFC 9207). Check it before you use the `code`. An unknown `client_id` or an unregistered `redirect_uri` still returns a non-redirecting JSON `400` (no attacker-controlled redirect target is ever used).

### Scopes

| Scope       | Grants                                                               |
| ----------- | -------------------------------------------------------------------- |
| `mcp:read`  | Read-only tool calls (`list_*`, `get_*`, etc.)                       |
| `mcp:write` | Read AND write tool calls (`create_*`, `update_*`, `delete_*`, etc.) |

Both metadata documents list these two scopes in `scopes_supported`, so a client can discover them before it asks for a grant.

`scope` on `/oauth/authorize` is **optional** (RFC 6749 §3.3) and negotiated leniently, since some MCP clients omit it or send values this server doesn't recognize:

- **Omitted or empty** `scope` → grants the full supported set (`mcp:read mcp:write`).
- **Unrecognized-only** values (e.g. `scope=claudeai`) → also fall back to the full supported set, identical to the omitted case.
- **A mix of known and unknown values** (e.g. `scope=mcp:read claudeai`) → grants only the known subset (`mcp:read`).
- **A valid subset** of supported scopes → granted unchanged.

The negotiated scope is what shows on the consent screen and what the token response's `scope` field reflects. The **effective** scope at call time is still the minimum of the grant's negotiated scope and the user's **current** role in that organization — a role downgrade after the grant was issued takes effect immediately, without waiting for the user to re-consent.

When a tool call needs a scope the grant does not hold, `/mcp` refuses the call before the tool runs, with `403` and `WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:write"` (or `scope="mcp:read"`), so an OAuth client can ask for that scope again. When the grant holds the scope but your current role does not allow it, the call returns a `forbidden` tool error that names the scope and your role, and no `insufficient_scope` challenge is sent. Authorizing again does not change that: the role caps what a new grant can hold, so an organization owner or admin must change your role. An API token has no scopes and is never refused this way.

### Refresh

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=...&client_id=...&resource=https%3A%2F%2Fapp.probara.net%2Fmcp
```

A `resource` that names another resource is refused with `400` and `error: invalid_target`, and the refresh token stays usable.

Each refresh **rotates** the token pair: the old refresh token stops working immediately. Reusing an already-rotated refresh token revokes the entire token family as a precaution — if this happens, the user must consent again.

Re-consenting does **not** require re-registering the client: as long as the client's `client_id` is still inside its consented expiry window (see [Dynamic Client Registration (DCR)](#dynamic-client-registration-dcr) above), the SAME `client_id` returns to `GET /oauth/authorize` to get a fresh grant. That window is guaranteed to outlast the refresh-token lifetime, so a client whose refresh token has just expired — or whose family was just revoked by reuse detection — can always re-authorize without a new DCR call.

### Managing and revoking grants

Users manage their own authorized clients at **Workspace → Authorized clients** — revoking there invalidates every access and refresh token issued to that client for that organization immediately.

## Session auth (web app only)

| Mechanism                                               | Used by              | Org context                                          |
| ------------------------------------------------------- | -------------------- | ---------------------------------------------------- |
| Session cookie (`tcms_session` / `__Host-tcms_session`) | Browser SPA          | `X-Organization-Id` header required on domain routes |
| Bearer API token                                        | Scripts, API clients | From token; no org header                            |
| Bearer app token                                        | CI reporting         | From token; no org header                            |

Session-only routes (Bearer is rejected): `POST /api/v1/auth/logout`, `logout-all`, `GET /api/v1/auth/me`, `GET`/`PATCH /api/v1/profile`, `POST`/`DELETE /api/v1/profile/avatar`, all of `/api/v1/me/api-tokens`, `PATCH /api/v1/orgs/{orgUlid}` (the organization rename), and the app token routes under `/api/v1/orgs/{orgUlid}/integrations`.

## User profile (session cookie)

| Method   | Path                     | Body                                                             | Response                                                           |
| -------- | ------------------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------ |
| `GET`    | `/api/v1/profile`        | —                                                                | `{ firstName, lastName, positionTitle, avatarKey, avatarVersion }` |
| `PATCH`  | `/api/v1/profile`        | `{ firstName, lastName, positionTitle? }`                        | Same shape as `GET`; empty `positionTitle` stored as `null`        |
| `POST`   | `/api/v1/profile/avatar` | `multipart/form-data` with one `file` (PNG/JPEG/WebP, max 5 MiB) | `{ avatarKey, avatarVersion }`                                     |
| `DELETE` | `/api/v1/profile/avatar` | —                                                                | `{ avatarKey: null, avatarVersion }`                               |

Avatar images are normalized server-side to 256×256 WebP and stored in R2. Public reads use `GET /__assets/{avatarKey}?v={avatarVersion}` (no session). `avatarKey` is `null` when the user has no photo.

`firstName` and `lastName` are required and non-empty on `PATCH`. No `X-Organization-Id` header is required.

## Signup contract

`POST /api/v1/auth/signup` requires `firstName` and `lastName` (trimmed, non-empty) in addition to `email` and `password`. Optional `invitationToken` is unchanged. CREATE and REPAIR persist the names on the `users` row; `positionTitle` remains profile-only (set later via `PATCH /api/v1/profile`).

## Audit log (read-only)

After you have a token, you can list organization audit events (who changed what) with the same Bearer credential:

`GET /api/v1/orgs/{orgUlid}/audit-events` — see [Audit events API](https://docs.probara.net/en/developers/api/audit-events/) for cursor pagination and response fields. Requires the audit read permission (held by `owner`/`admin`).

## Errors

| Status | Code                               | Meaning                                                                                                                                                                                                                        |
| ------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 401    | `unauthorized`                     | Missing/invalid cookie or Bearer token                                                                                                                                                                                         |
| 403    | `forbidden`                        | An app token called something outside its [allowed calls](#allowed-calls), or the organization has no paid plan for [CI reporting](#plan)                                                                                      |
| 403    | `email_not_verified`               | The user has not verified their email address yet. Returned when creating API tokens, approving an MCP client, inviting members, accepting an invitation by email, connecting Google, or turning on two-factor authentication. |
| 403    | `organization_two_factor_required` | The organization requires two-factor authentication and your account has no confirmed authenticator app. Returned when creating API tokens or approving an MCP client for that organization.                                   |
| 429    | `too_many_requests`                | Token creation rate limit (10 per user per hour, for API tokens and app tokens alike)                                                                                                                                          |

## Rate limits

Creating API tokens is limited to **10 requests per user per hour** (configurable in the Worker), and creating app tokens has a budget of its own of the same size. `POST /api/v1/auth/signup` is rate-limited along two dimensions: **5 per hour per IP** and **3 per hour per email**. Excess requests return `429 too_many_requests`.

`POST /api/v1/auth/login` is limited to **5 per minute per IP** and **10 per hour per email**. A browser that has already signed in to that address keeps its own budget of 10 per hour for it, so failed attempts from elsewhere cannot lock that browser out. Excess requests return `429 too_many_requests`.
