# Authentication

> **For agents:** you authenticate with `Authorization: Bearer bwt_…`; `whoami()` tells you which organization the token belongs to and its scopes. Errors: `401` bad or expired token, `403` missing scope, `404` unknown org/project — including organizations the token cannot see.

## Two principals

**API tokens** — `Authorization: Bearer bwt_…`. Bound to one organization, with an explicit scope list and optional expiry. Stored as SHA-256; shown once at creation. Used by scripts, CI, `sentry-cli` (`SENTRY_AUTH_TOKEN`), and MCP clients. See [Tokens & scopes](https://docs.bugwatch.io/agents/tokens-and-scopes.md).

**Sessions** — the `bugwatch_session` cookie the dashboard sets after sign-in (`HttpOnly`, `Secure`, `SameSite=Lax`, 30 days; only its SHA-256 is stored). A session user's scopes for an organization are derived from their membership role on every request. The API allows the dashboard origin with credentials, so the cookie works cross-subdomain from `app.bugwatch.io`.

**Cross-site requests.** A state-changing request that carries the session cookie, and every `/auth/*` POST, must come from the dashboard's origin (or the API's own host) and, if it has a body, send `application/json`. Anything else is refused: `403` for a foreign `Origin`, `415` for another body type. Bearer-token requests are not affected, because a token is never sent by a browser on its own.

**Rate limits.** Sign-in, signup, magic links, `/oauth/token`, `/oauth/register` and status-page subscribe are limited per client IP (20 a minute per route), and password sign-in also per account (5 a minute), whatever IP the attempts come from. One address gets at most 5 sign-in or confirmation emails an hour. Over a limit the answer is `429` with `Retry-After`.

Every `/v1/*` and `/api/0/*` route accepts either. `/auth/*` routes handle sign-in and need neither. `/stripe/webhook` authenticates by Stripe signature.

## Sign-in routes

| Route | Purpose |
|---|---|
| `POST /auth/signup` | `{email, password (≥10), name?, orgName, orgSlug}` → creates user + org (owner). With an email provider configured: `202 {verifyEmail: true}` and a confirmation link is emailed, with no cookie until the link is followed (the same answer whether or not the address already had an account). Without one: `201` and sets the cookie |
| `POST /auth/login` | `{email, password}`; uniform error and timing on unknown emails |
| `POST /auth/logout` | Deletes the session |
| `GET /auth/me` | Current user and their org memberships |
| `GET /auth/providers` | Which of `google`, `github`, `microsoft`, `magicLink` are enabled on this deployment |
| `GET /auth/oauth/{provider}/start?next=/path` | Begin OAuth; callback is `GET /auth/oauth/{provider}/callback` |
| `POST /auth/magic` → `POST /auth/magic/verify` | Email magic link (needs the email provider). Each link works once. `/auth/login` answers `403 {code: "email_unverified"}` for an unconfirmed password account and re-sends its confirmation link |
| `POST /auth/orgs` | Create a first organization after social/magic sign-in |

Social identities are linked by (provider, subject); an email match links to an existing account only when the provider reports it verified.

## Organization scoping

Every resource route is under `/v1/orgs/{org}/…`. The handler resolves the slug, then checks:

1. A token must belong to that organization; a session user must be a member. Otherwise **`404`** — cross-org requests never reveal that the org exists.
2. The required scope must be satisfied (`hasScope`: exact match, `org:admin` implies all, `:write` implies `:read`). Otherwise **`403 {"error": "insufficient scope"}`**.

Projects are addressed by slug within the org; unknown projects, issues, keys, or rules are `404`.

## Status codes

| Code | Meaning |
|---|---|
| `401` | No credentials, invalid token, expired token, or expired session |
| `403` | Authenticated but missing the scope |
| `404` | Org not accessible, or resource not found |
| `400` | Validation failure; body is `{"error": {"formErrors": [], "fieldErrors": {…}}}` (Zod) |
| `402` | Plan limit (adding a billable member beyond the plan's seats) |
| `409` | Slug already taken |
| `413` | Upload too large |
| `501` | Feature not configured on this deployment (Stripe, an OAuth provider, email) |

## Example

```sh
curl -sS https://api.bugwatch.io/v1/whoami -H 'Authorization: Bearer bwt_…'
# {"via":"token","tokenName":"claude-code","user":{"id":"01J…","email":"you@example.com","name":null},
#  "orgs":[{"slug":"acme","name":"Acme","scopes":["org:read","project:read","event:read"]}]}
```

Ingest uses a different credential entirely — the DSN public key in `X-Sentry-Auth` — and can only *send* events; see [SDKs overview](https://docs.bugwatch.io/sdks/overview.md).
