Skip to content

API authentication

Integrations authenticate with an organization-scoped API token sent as a Bearer credential. The Probara web app continues to use session cookies plus X-Organization-Id for org switching.

  1. Sign in to Probara and open Workspace → API tokens (/workspace/api-tokens).
  2. In the New token section, enter a name, pick the organization (shared dropdown, limited to your memberships), and click Create token.
  3. Copy the secret from the amber 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.

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

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:

Terminal window
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://probara.net/api/v1/projects"

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 for per-client setup steps.

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

Metadata documentPath
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.

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

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. 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).

  1. The client redirects the user to GET /oauth/authorize with a PKCE code_challenge (S256), the exact registered redirect_uri, and an optional scope — see Scopes below for what happens when it is omitted or unrecognized.
  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.
  4. The client exchanges the code for tokens:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...&client_id=...

Once client_id and redirect_uri are validated, any further authorize error (unsupported response_type, invalid PKCE) 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. An unknown client_id or an unregistered redirect_uri still returns a non-redirecting JSON 400 (no attacker-controlled redirect target is ever used).

ScopeGrants
mcp:readRead-only tool calls (list_*, get_*, etc.)
mcp:writeRead AND write tool calls (create_*, update_*, delete_*, etc.)

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.

POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=...&client_id=...

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) 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.

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.

MechanismUsed byOrg context
Session cookie (tcms_session / __Host-tcms_session)Browser SPAX-Organization-Id header required on domain routes
Bearer API tokenCI, scripts, API clientsFrom 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, and all of /api/v1/me/api-tokens.

MethodPathBodyResponse
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/avatarmultipart/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.

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).

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 for cursor pagination and response fields. Requires owner or admin on that organization.

StatusCodeMeaning
401unauthorizedMissing/invalid cookie or Bearer token
403email_not_verifiedResidual: returned for legacy users whose email_verified_at is NULL (backups, manual DB edits). New signups are auto-verified and never trigger this.
429too_many_requestsToken creation rate limit (10 per user per hour)
500email_send_failedReturned by request-password-reset (and the COLLISION branch of signup) when the upstream email provider rejects the send. The user can retry.

Creating API tokens is limited to 10 requests per user per hour (configurable in the Worker). 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.