> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Email/password signup, Google sign-in, device pairing for the CLI, active-session management, long-lived CLI tokens, and email-based password recovery.

# Authentication

Authentication surface in Crewship is split across several flows. The interactive web flow is `/api/auth/*` (NextAuth-compatible -- see [Internal API](/api-reference/internal)); everything covered on this page is the `/api/v1/auth/*` surface that wraps email/password signup, Google sign-in, CLI pairing, active session listing/revocation, long-lived CLI tokens, and password recovery.

All `/api/v1/auth/*` routes are mounted behind a rate limit of 10 req/min/IP. Routes marked **auth required** read the caller's identity from the session cookie issued by `/api/auth/callback/credentials`; the rest are intentionally unauthenticated because the request body (a state token, pairing code, or reset token) IS the credential.

## Endpoints

| Method | Endpoint                                                                      | Purpose                                   |
| ------ | ----------------------------------------------------------------------------- | ----------------------------------------- |
| POST   | [`/api/auth/token/refresh`](#post-api-auth-token-refresh)                     | Rotate the browser session token          |
| GET    | [`/api/auth/error`](#get-api-auth-error)                                      | NextAuth error redirect target            |
| POST   | [`/api/v1/auth/signup`](#post-api-v1-auth-signup)                             | Email/password signup                     |
| GET    | [`/api/v1/auth/google/status`](#get-api-v1-auth-google-status)                | Is Google sign-in enabled                 |
| GET    | [`/api/v1/auth/google/redirect`](#get-api-v1-auth-google-redirect)            | Start the Google OAuth2 flow              |
| GET    | [`/api/v1/auth/google/callback`](#get-api-v1-auth-google-callback)            | Google OAuth2 redirect target             |
| POST   | [`/api/v1/auth/pair/start`](#post-api-v1-auth-pair-start)                     | Mint a device-pairing code                |
| GET    | [`/api/v1/auth/pair/poll`](#get-api-v1-auth-pair-poll)                        | Poll a pairing code's status              |
| POST   | [`/api/v1/auth/pair/redeem`](#post-api-v1-auth-pair-redeem)                   | Redeem a code for a CLI token             |
| GET    | [`/api/v1/auth/sessions`](#get-api-v1-auth-sessions)                          | List the caller's active sessions         |
| POST   | [`/api/v1/auth/sessions/{id}/revoke`](#post-api-v1-auth-sessions-id-revoke)   | Revoke a single session                   |
| POST   | [`/api/v1/auth/cli-token`](#post-api-v1-auth-cli-token)                       | Mint a long-lived CLI token               |
| GET    | [`/api/v1/auth/cli-token/validate`](#get-api-v1-auth-cli-token-validate)      | Validate the current CLI token            |
| GET    | [`/api/v1/auth/cli-tokens`](#get-api-v1-auth-cli-tokens)                      | List the caller's CLI tokens              |
| DELETE | [`/api/v1/auth/cli-tokens/{tokenId}`](#delete-api-v1-auth-cli-tokens-tokenid) | Revoke a CLI token                        |
| POST   | [`/api/v1/auth/forgot`](#post-api-v1-auth-forgot)                             | Request a password-reset email            |
| POST   | [`/api/v1/auth/reset`](#post-api-v1-auth-reset)                               | Consume a reset token, set a new password |

***

## NextAuth-compatible browser session

The interactive web flow lives under `/api/auth/*` (no `/v1`). These are the NextAuth-compatible routes the browser uses for the cookie-based session; the full table is in the [API overview](/api-reference/overview#nextauth-compatibility). Two of them are worth calling out here:

### `POST /api/auth/token/refresh`

Rotates the session token. The browser calls this when its short-lived access token nears expiry to obtain a fresh one without re-entering credentials.

**Auth:** the `refresh-token` cookie IS the credential (no `Authorization` header).

**Behaviour:** layered CSRF defence (POST-only, path-scoped `SameSite=Lax` refresh cookie, and a same-origin `Origin`/`Referer` check). Implements refresh-token **rotation with reuse detection** — each refresh token carries a unique JTI, the session row tracks the current JTI, and a successful refresh CAS-rotates old → new. A request replaying an already-rotated JTI is treated as token theft: the **entire session is revoked** and the call returns `401`. On any failure both auth cookies are cleared so a dead token isn't resent.

| Status | Condition                                                                                          |
| ------ | -------------------------------------------------------------------------------------------------- |
| `200`  | New access + refresh cookies set.                                                                  |
| `401`  | Missing/expired/invalid refresh cookie, session not found, revoked, or inactive (cookies cleared). |
| `403`  | Cross-origin request (`Origin`/`Referer` check failed).                                            |
| `405`  | Non-POST method.                                                                                   |
| `500`  | Sessions store unreachable or token-mint failure (cookies preserved so the client can retry).      |

### `GET /api/auth/error`

NextAuth error redirect target. Echoes the `?error=` query value back as JSON (defaulting to `Default` when absent) so the login UI can render a human-readable message.

**Auth:** none.

**Response:** `200 OK`

```json theme={null}
{ "error": "CredentialsSignin", "message": "Authentication error: CredentialsSignin" }
```

***

## Email/password signup

### `POST /api/v1/auth/signup`

Create a new user, their starter workspace, and the OWNER workspace
membership in a single transaction, then set the session cookie so
the client is signed in immediately. Off by default — enable with
`CREWSHIP_ALLOW_SIGNUP=true` at startup. On a closed instance
(default), the endpoint returns `403` with the env-var name in the
error message.

**Request body:**

```json theme={null}
{
  "full_name": "Alice Chen",
  "email": "alice@example.com",
  "password": "correct-horse-battery-staple"
}
```

| Field       | Rule                                       |
| ----------- | ------------------------------------------ |
| `full_name` | Min 2 characters.                          |
| `email`     | Must match the server's basic email regex. |
| `password`  | Min 8 characters.                          |

**Response:** `201 Created`. The body is just the new user's `id` and
`email` — the workspace and OWNER membership are created server-side
but are not echoed back. A `Set-Cookie: __Secure-authjs.session-token=...`
header is set, so the caller is signed in — no follow-up login call needed.

```json theme={null}
{
  "id": "u_01HVY...",
  "email": "alice@example.com"
}
```

| Status | Condition                                                                                  |
| ------ | ------------------------------------------------------------------------------------------ |
| `400`  | Field fails one of the validation rules above.                                             |
| `403`  | Signup is disabled (`CREWSHIP_ALLOW_SIGNUP` is unset/false).                               |
| `409`  | Email already registered on this instance.                                                 |
| `500`  | DB transaction or session-cookie issue failed; account rolled back so no orphan rows land. |

<Note>
  Signup is distinct from [`POST /api/v1/bootstrap`](/api-reference/overview) — bootstrap is the one-shot empty-instance flow that always creates the first OWNER regardless of `CREWSHIP_ALLOW_SIGNUP`. Signup is the steady-state "add another user to this open instance" surface. Most production deployments leave signup off and onboard via [device pairing](#device-pairing) or direct admin invites.
</Note>

***

## Google sign-in

Three endpoints implement the OAuth2 redirect dance. They are wired only when `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set at startup; otherwise `/redirect` and `/callback` are not registered at all and `/status` reports `enabled: false`.

### `GET /api/v1/auth/google/status`

Probe that tells the login page whether to render the "Continue with Google" button.

**Auth:** none.

**Response:** `200 OK`

```json theme={null}
{ "enabled": true }
```

| Field     | Type    | Description                                                                           |
| --------- | ------- | ------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` when both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set on the server. |

### `GET /api/v1/auth/google/redirect`

Starts the OAuth2 flow. The handler mints a single-use state token (32 random bytes, stored in `oauth_states` with the requested post-login redirect, 15-min TTL) and 307s the browser to `accounts.google.com`.

**Auth:** none.

**Query parameters:**

| Param      | Type   | Description                                                                                                               |
| ---------- | ------ | ------------------------------------------------------------------------------------------------------------------------- |
| `redirect` | string | Optional post-login destination. Validated by `isSafeRedirect`; anything that escapes the local origin falls back to `/`. |

**Response:** `307 Temporary Redirect` to the Google authorization URL. Errors land as `404` ("Google sign-in not configured") when OAuth is disabled or `500` on a state-insert failure.

### `GET /api/v1/auth/google/callback`

Google's redirect target. Atomically consumes the state token (single-use via `DELETE ... RETURNING`), exchanges the auth code for an access token, fetches the user's profile from `https://www.googleapis.com/oauth2/v3/userinfo`, upserts the row in `users` + `accounts`, and mints a fresh `user_sessions` row plus access/refresh cookies. Then 307s the browser to the validated `redirect_uri` captured during `/redirect`.

**Auth:** none.

**Query parameters:**

| Param   | Type   | Description                                  |
| ------- | ------ | -------------------------------------------- |
| `state` | string | Required. The opaque token from `/redirect`. |
| `code`  | string | Required. Google's authorization code.       |

| Status | Condition                                                                                                    |
| ------ | ------------------------------------------------------------------------------------------------------------ |
| `307`  | Success -- session cookies set, browser redirected.                                                          |
| `400`  | Missing `state`/`code`, unknown state, state older than 15 min, or Google code exchange failed.              |
| `404`  | Google sign-in not configured (env vars missing).                                                            |
| `502`  | Userinfo fetch or JSON decode failed.                                                                        |
| `500`  | DB insert, session create, or token mint failed (a ghost session, if created, is revoked before responding). |

***

## Device pairing

A device-code flow (RFC 8628 in spirit) that hands a Crewship session to a locally-installed CLI without copy-pasting a long token. The UI calls `/pair/start` to mint a human-typeable code, polls `/pair/poll` for status, and the CLI on the operator's machine exchanges the same code at `/pair/redeem` for a long-lived CLI token. Codes are 8 chars of Crockford-ish base32 (no `0/O/1/I/L`) formatted as `XXXX-XXXX`, 10-minute TTL.

### `POST /api/v1/auth/pair/start`

Mint a new pairing code for the authenticated user.

**Auth:** required.

**Request body** (optional):

```json theme={null}
{ "adapter_hint": "CLAUDE_CODE" }
```

| Field          | Type   | Description                                                                                                        |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------ |
| `adapter_hint` | string | Optional. `[A-Z_0-9]` up to 32 chars; anything else is stripped. Telemetry-only -- the backend never routes on it. |

**Response:** `200 OK`

```json theme={null}
{
  "code": "K3F9-X2NM",
  "expires_at": "2026-05-20T08:50:00Z"
}
```

### `GET /api/v1/auth/pair/poll`

UI polls this every \~2s while showing the code to the user. The row is reported as `expired` when (a) the code doesn't exist, (b) it belongs to a different user, or (c) the TTL has passed -- the three cases are indistinguishable on purpose so `/poll` cannot enumerate other users' codes.

**Auth:** required.

**Query parameters:**

| Param  | Type   | Description                                                               |
| ------ | ------ | ------------------------------------------------------------------------- |
| `code` | string | Required. The code returned by `/pair/start`. Case- and dash-insensitive. |

**Response:** `200 OK`

```json theme={null}
{
  "status": "pending",
  "adapter_hint": "CLAUDE_CODE",
  "expires_at": "2026-05-20T08:50:00Z"
}
```

| Field          | Type    | Description                                              |
| -------------- | ------- | -------------------------------------------------------- |
| `status`       | string  | One of `pending`, `consumed`, `expired`.                 |
| `adapter_hint` | string? | Echo of the hint stored on `/start`. Omitted when empty. |
| `expires_at`   | string  | RFC3339.                                                 |

### `POST /api/v1/auth/pair/redeem`

The CLI calls this from the operator's machine to exchange the code for a fresh `cli_tokens` row. **Unauthenticated by design** -- the code IS the credential. Single-use (the UPDATE filters on `status='pending'` so two concurrent redeems can't both win), 10-min TTL, and the route inherits the 10 req/min/IP cap from the auth-tier rate limiter.

**Auth:** none.

**Request body:**

```json theme={null}
{
  "code": "K3F9-X2NM",
  "adapter_hint": "CLAUDE_CODE"
}
```

| Field          | Type   | Description                                             |
| -------------- | ------ | ------------------------------------------------------- |
| `code`         | string | Required. The pairing code, case- and dash-insensitive. |
| `adapter_hint` | string | Optional. Same `[A-Z_0-9]≤32` sanitisation as `/start`. |

**Response:** `200 OK`

```json theme={null}
{
  "cli_token": "crewship_cli_3f9a4d2b8e7c1f5a0d6b9e2c4f8a1d3b5e7c9f1a",
  "user_id": "u_01HVY...",
  "email": "petra@example.com"
}
```

| Status | Condition                                                                                  |
| ------ | ------------------------------------------------------------------------------------------ |
| `400`  | Invalid JSON, missing/normalised code, unknown code, status not `pending`, or TTL expired. |
| `500`  | DB read/write failure (token is not minted on error).                                      |

***

## Active session management

The Settings → Sessions UI uses these endpoints to show the user every device currently signed in, with a "revoke" button per row. Revoking the caller's own session is allowed; the frontend handles the resulting 401 by hard-redirecting to `/login`.

### `GET /api/v1/auth/sessions`

Lists the caller's active (non-revoked) sessions, newest-active first.

**Auth:** required.

**Response:** `200 OK`

```json theme={null}
[
  {
    "id": "sess_01HVZ...",
    "created_at": "2026-05-20T07:55:14Z",
    "last_used_at": "2026-05-20T08:31:02Z",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)",
    "ip": "203.0.113.42",
    "is_current": true
  }
]
```

| Field          | Type    | Description                                                                                                                 |
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
| `id`           | string  | `user_sessions.id`. Pass back to `/revoke`.                                                                                 |
| `created_at`   | string  | RFC3339.                                                                                                                    |
| `last_used_at` | string  | RFC3339, refreshed by the auth middleware on every authenticated request.                                                   |
| `user_agent`   | string  | Captured at session create. Omitted when empty.                                                                             |
| `ip`           | string  | Client IP recorded at session create. Omitted when empty.                                                                   |
| `is_current`   | boolean | `true` for the session backing this very request -- the UI uses this to add a "this device" badge and warn before revoking. |

### `POST /api/v1/auth/sessions/{id}/revoke`

Flips `revoked_at` on a single session owned by the caller. The 404 path covers both "doesn't exist" and "doesn't belong to you" so callers can't enumerate other users' session ids by guessing.

**Auth:** required.

**Path parameters:**

| Param | Description                                |
| ----- | ------------------------------------------ |
| `id`  | `user_sessions.id` from the list endpoint. |

**Response:** `200 OK`

```json theme={null}
{
  "ok": true,
  "id": "sess_01HVZ...",
  "is_current": false
}
```

| Status | Condition                                             |
| ------ | ----------------------------------------------------- |
| `400`  | Empty `id`.                                           |
| `404`  | Session doesn't exist or belongs to a different user. |
| `500`  | DB error during the revoke write.                     |

***

## CLI tokens

Long-lived bearer tokens used by `crewship` CLI invocations and CI agents. Tokens are minted once, only the SHA-256 hash is stored, and the raw value is returned exactly once in the `create` response -- the rest of the surface returns metadata only.

Token format: `crewship_cli_` + 40 hex chars (20 random bytes). `IsCLIToken` is the helper the auth middleware uses to detect this prefix before falling through to JWE validation.

### `POST /api/v1/auth/cli-token`

Mints a new token for the calling user.

**Auth:** required.

**Request body** (optional):

```json theme={null}
{
  "name": "ci-runner",
  "tier": "STANDARD",
  "expires_in_seconds": 86400,
  "scopes": ["agents:read", "agents:run"]
}
```

| Field                | Type      | Description                                                                                                                                                                                                                                                                                                                           |
| -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`               | string    | Optional. Defaults to `"CLI token"`. Free-form label rendered in the Settings list.                                                                                                                                                                                                                                                   |
| `tier`               | string    | Optional. `STANDARD` (default) or `ADMIN`. `STANDARD` tokens never expire unless `expires_in_seconds` is given; `ADMIN` tokens are OWNER-only, HMAC-signed, and default to a bounded lifetime (capped server-side).                                                                                                                   |
| `expires_in_seconds` | integer   | Optional. TTL in seconds from issue time. `0`/omitted means "tier default" (STANDARD: no expiry; ADMIN: the server's default ADMIN lifetime, clamped to the ADMIN max and floored at 60s).                                                                                                                                            |
| `scopes`             | string\[] | Optional. Narrows the token below the caller's full workspace role. Empty list = full role (historical behaviour). Each entry must be a known `resource:action` scope (e.g. `agents:read`, `crews:write`, `workspace:admin`, or wildcards `*` / `agents:*`); an unknown scope is `400`, a scope exceeding the caller's role is `403`. |

**Response:** `200 OK`

```json theme={null}
{
  "token": "crewship_cli_3f9a4d2b8e7c1f5a0d6b9e2c4f8a1d3b5e7c9f1a",
  "id": "clt_01HVZ...",
  "name": "ci-runner",
  "tier": "STANDARD",
  "created_at": "2026-05-20T08:31:02Z"
}
```

| Field        | Type       | Description                                                                                                               |
| ------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| `token`      | string     | The raw bearer token. Returned exactly once.                                                                              |
| `id`         | string     | `cli_tokens.id`.                                                                                                          |
| `name`       | string     | The label (defaults to `"CLI token"`).                                                                                    |
| `tier`       | string     | Always present — `STANDARD` or `ADMIN`, echoing the resolved tier.                                                        |
| `created_at` | string     | RFC3339 issue time.                                                                                                       |
| `expires_at` | string?    | RFC3339 expiry. Present only when the token has one (any ADMIN token, or a STANDARD token with `expires_in_seconds` set). |
| `scopes`     | string\[]? | Echo of the normalised scope list. Present only when a non-empty `scopes` was supplied.                                   |

<Warning>
  The `token` field is the **only** time the raw value leaves the server. Store it immediately -- there is no recovery path. Lost tokens must be revoked and replaced.
</Warning>

### `GET /api/v1/auth/cli-token/validate`

Confirms the current request's CLI token (the `Authorization: Bearer ...` header) is valid. Used by `crewship login --status` and the CLI's startup self-check.

**Auth:** required (call this with the token you want to verify).

**Response:** `200 OK`

```json theme={null}
{
  "valid": true,
  "user_id": "u_01HVY...",
  "user_email": "petra@example.com"
}
```

A revoked or unknown token never reaches this handler -- the auth middleware rejects with `401` before dispatch.

### `GET /api/v1/auth/cli-tokens`

Lists all CLI tokens belonging to the calling user, newest first. The plaintext is never returned.

**Auth:** required.

**Response:** `200 OK`

```json theme={null}
{
  "data": [
    {
      "id": "clt_01HVZ...",
      "name": "ci-runner",
      "tier": "STANDARD",
      "created_at": "2026-05-20T08:31:02Z",
      "last_used_at": "2026-05-20T08:42:15Z"
    },
    {
      "id": "clt_01HVX...",
      "name": "pair-claude_code",
      "tier": "STANDARD",
      "created_at": "2026-05-19T14:02:11Z",
      "revoked_at": "2026-05-19T19:18:00Z"
    }
  ]
}
```

| Field          | Type    | Description                                                                                                                   |
| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`           | string  | Token id.                                                                                                                     |
| `name`         | string  | Operator-supplied label. Pairing-flow tokens are named `pair[-<adapter_hint>]`.                                               |
| `tier`         | string  | Always present — `STANDARD` or `ADMIN`.                                                                                       |
| `created_at`   | string  | RFC3339.                                                                                                                      |
| `expires_at`   | string? | RFC3339 expiry. Present only when the token has one (any ADMIN token, or a STANDARD token created with `expires_in_seconds`). |
| `last_used_at` | string? | RFC3339 of the last successful auth. Updated asynchronously by `ValidateCLIToken`. Omitted on tokens never used.              |
| `revoked_at`   | string? | RFC3339 when `/auth/cli-tokens/{tokenId}` was called. Omitted on still-active tokens.                                         |

### `DELETE /api/v1/auth/cli-tokens/{tokenId}`

Marks a token revoked. Subsequent uses are rejected by the auth middleware (`ValidateCLIToken` returns "CLI token revoked").

**Auth:** required.

**Path parameters:**

| Param     | Description                             |
| --------- | --------------------------------------- |
| `tokenId` | `cli_tokens.id` from the list endpoint. |

**Response:** `200 OK`

```json theme={null}
{ "status": "revoked" }
```

| Status | Condition                                           |
| ------ | --------------------------------------------------- |
| `404`  | Token doesn't exist or belongs to a different user. |
| `500`  | DB error during the revoke write.                   |

***

## Password recovery

Email-based recovery for users who don't have shell access to the box. Admins should prefer `crewship admin reset-password` -- it writes directly to the DB and doesn't require a mailer.

The mailer transport is wired at startup from `RESEND_API_KEY` + `RESEND_FROM`. The reset link is built from `CREWSHIP_PUBLIC_URL` (a validated `http(s)://host` value parsed once at boot). When the public URL is unset or malformed, `/forgot` silently no-ops -- without it there's no safe way to build a link that isn't `Host`-header-injection-controllable.

### `POST /api/v1/auth/forgot`

Issues a single-use, 30-min reset token if the email matches a user **and** a mailer transport is configured. Always returns `200` with the same JSON body regardless -- the endpoint cannot be used to enumerate accounts.

**Auth:** none.

**Request body:**

```json theme={null}
{ "email": "petra@example.com" }
```

**Response:** `200 OK`

```json theme={null}
{
  "ok": true,
  "message": "If an account exists for that email and email is configured on this server, a reset link has been sent. Self-hosted administrators without email configured should run `crewship admin reset-password` on the server."
}
```

The response body is byte-for-byte identical for "no such email", "mailer disabled", "public URL unset", and a successful send -- the only signal a successful send happened is the user receiving the email.

### `POST /api/v1/auth/reset`

Consumes the reset token and sets a new password. The token row is burned inside a transaction (race-protected: two concurrent calls with the same token serialise on the DELETE, only one wins). On success, **every** active session for the user is revoked so a stolen cookie can't outlive the reset.

**Auth:** none -- the token IS the credential.

**Request body:**

```json theme={null}
{
  "token": "EXAMPLE_64_HEX_PASSWORD_RESET_TOKEN_NOT_REAL",
  "new_password": "correct horse battery staple"
}
```

| Field          | Type   | Description                                                                                                               |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- |
| `token`        | string | Required. The raw 32-byte hex token from the email link. Server compares its SHA-256 against `verification_tokens.token`. |
| `new_password` | string | Required. Minimum 8 characters. Hashed with bcrypt (cost 12) before storage.                                              |

**Response:** `200 OK`

```json theme={null}
{ "ok": true }
```

| Status | Condition                                                                                                                                                              |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Invalid JSON, missing token, password shorter than 8 chars, unknown/expired token, or token already consumed by a parallel call.                                       |
| `500`  | bcrypt failure, DB error, **or** session revocation failed after the password write committed (the body explains the user must retry login; the password was updated). |

<Note>
  Password reset is one of the few endpoints where a 500 with a non-generic message is intentional: if the password write commits but session revocation fails, leaving the user with valid old cookies would silently undo the security purpose of the reset. The 500 forces an admin-visible log entry.
</Note>

***

## See also

* [Internal API](/api-reference/internal) -- the NextAuth-compatible `/api/auth/*` surface used by the browser session cookie flow.
* [Security overview](/security/rbac) -- how the session token feeds into RBAC.
* [`crewship login`](/cli/login) -- CLI commands that consume `/pair/start` + `/pair/redeem` and store the resulting token in `~/.crewship/cli-config.yaml`.
