# Auth

> Sign-in and token issuance.

Sign-in and token issuance. Humans use passkeys (WebAuthn). The CLI uses the OAuth 2.0 device flow
(RFC 8628) and receives a one-hour access token plus a refresh token. A development-only login exists
when the server runs with `GUV_DEV_LOGIN=true`.

| Operation | Method and path |
| --- | --- |
| [Sign in without a passkey (development only)](#sign-in-without-a-passkey-development-only) | `POST /v1/auth/dev-login` |
| [Send a sign-up verification code by email](#send-a-sign-up-verification-code-by-email) | `POST /v1/auth/email-verification` |
| [Start registering a passkey (sign-up, or add a passkey when signed in)](#start-registering-a-passkey-sign-up-or-add-a-passkey-when-signed-in) | `POST /v1/auth/passkeys/register/begin` |
| [Finish registering a passkey](#finish-registering-a-passkey) | `POST /v1/auth/passkeys/register/finish` |
| [Start signing in with a passkey](#start-signing-in-with-a-passkey) | `POST /v1/auth/passkeys/login/begin` |
| [Finish signing in with a passkey](#finish-signing-in-with-a-passkey) | `POST /v1/auth/passkeys/login/finish` |
| [Start a passkey assertion for a sensitive action](#start-a-passkey-assertion-for-a-sensitive-action) | `POST /v1/auth/step-up/begin` |
| [Start the CLI device flow (RFC 8628)](#start-the-cli-device-flow-rfc-8628) | `POST /v1/auth/device/code` |
| [Show which device is asking to sign in](#show-which-device-is-asking-to-sign-in) | `GET /v1/auth/device` |
| [Approve or deny a CLI sign-in (signed-in user)](#approve-or-deny-a-cli-sign-in-signed-in-user) | `POST /v1/auth/device/decision` |
| [Exchange a device code, authorisation code or refresh token for an access token](#exchange-a-device-code-authorisation-code-or-refresh-token-for-an-access-token) | `POST /v1/auth/token` |
| [Sign out the current session](#sign-out-the-current-session) | `POST /v1/auth/logout` |

## Sign in without a passkey (development only)

```http
POST /v1/auth/dev-login
```

Exists only when the server runs with `GUV_DEV_LOGIN=true`; otherwise returns `404 dev_login_disabled`. Creates the user (and a personal workspace) if needed and sets the session cookie.

- **Operation:** `devLogin`
- **Scopes:** none (public)
- **CLI:** `guv login --dev`
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [DevLoginRequest](/docs/api/objects/dev-login-request/)

| Field | Type | Description |
| --- | --- | --- |
| `email` * | string (email) |  |
| `name` | string |  |
| `platform_admin` | boolean | Make the user a platform admin (development only). Default `false`. |
| `create_workspace` | boolean | Create a personal workspace if the user has none. Default `true`. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [LoginResponse](/docs/api/objects/login-response/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/dev-login \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "dev@guv.local",
  "name": "Dev User",
  "platform_admin": false,
  "create_workspace": true
}'
```

**Example response**

```json
{
  "session": {
    "id": "ses_01jbx2m9k7q4r8t5v6w3y0z1pr",
    "kind": "browser",
    "auth_method": "passkey",
    "user_agent": "string",
    "ip": "string",
    "current": false,
    "created_at": "2026-10-22T13:05:00Z",
    "last_seen_at": "2026-10-22T13:05:00Z",
    "expires_at": "2026-10-22T13:05:00Z"
  },
  "access_token": "string",
  "csrf_token": "string",
  "user": {
    "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
    "email": "stewart@primecarers.co.uk",
    "name": "Stewart Webb",
    "avatar_url": null,
    "github_login": "stewartwebb",
    "has_passkey": true,
    "is_platform_admin": true,
    "default_workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
    "created_at": "2026-10-22T09:00:00Z"
  },
  "new_user": false
}
```

## Send a sign-up verification code by email

```http
POST /v1/auth/email-verification
```

- **Operation:** `startEmailVerification`
- **Scopes:** none (public)
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [EmailVerificationRequest](/docs/api/objects/email-verification-request/)

| Field | Type | Description |
| --- | --- | --- |
| `email` * | string (email) |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `202` Accepted | Accepted. | [EmailVerificationStarted](/docs/api/objects/email-verification-started/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/email-verification \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "ada@example.co.uk"
}'
```

**Example response**

```json
{
  "verification_id": "string",
  "expires_at": "2026-10-22T13:05:00Z"
}
```

## Start registering a passkey (sign-up, or add a passkey when signed in)

```http
POST /v1/auth/passkeys/register/begin
```

- **Operation:** `beginPasskeyRegistration`
- **Scopes:** any signed-in principal
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [PasskeyRegisterBeginRequest](/docs/api/objects/passkey-register-begin-request/)

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Label for the passkey. |
| `email` | string (email) |  |
| `display_name` | string |  |
| `verification_id` | string |  |
| `code` | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [PasskeyChallenge](/docs/api/objects/passkey-challenge/) |

**Errors:** `400`, `401`, `403`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/passkeys/register/begin \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "MacBook Touch ID",
  "email": "ada@example.co.uk",
  "display_name": "string",
  "verification_id": "string",
  "code": "string"
}'
```

**Example response**

```json
{
  "challenge_id": "string",
  "expires_at": "2026-10-22T13:05:00Z",
  "public_key_creation": {
    "challenge": "string",
    "rp": {
      "id": "guv.dev",
      "name": "Guv"
    },
    "user": {
      "id": "string",
      "name": "string",
      "displayName": "string"
    },
    "pubKeyCredParams": [
      {}
    ],
    "timeout": 1,
    "authenticatorSelection": {},
    "attestation": "string",
    "excludeCredentials": [
      {}
    ]
  },
  "public_key_request": {
    "challenge": "string",
    "rpId": "string",
    "timeout": 1,
    "userVerification": "required",
    "allowCredentials": [
      {}
    ]
  }
}
```

## Finish registering a passkey

```http
POST /v1/auth/passkeys/register/finish
```

Signs the new user in when this completes a sign-up.

- **Operation:** `finishPasskeyRegistration`
- **Scopes:** any signed-in principal
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [PasskeyFinishRequest](/docs/api/objects/passkey-finish-request/)

| Field | Type | Description |
| --- | --- | --- |
| `challenge_id` * | string |  |
| `credential` * | [WebAuthnCredential](/docs/api/objects/web-authn-credential/) |  |
| `name` | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [LoginResponse](/docs/api/objects/login-response/) |

**Errors:** `400`, `401`, `403`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/passkeys/register/finish \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "challenge_id": "string",
  "credential": {
    "id": "string",
    "rawId": "string",
    "type": "public-key",
    "response": {},
    "authenticatorAttachment": "string",
    "clientExtensionResults": {}
  },
  "name": "string"
}'
```

**Example response**

```json
{
  "session": {
    "id": "ses_01jbx2m9k7q4r8t5v6w3y0z1pr",
    "kind": "browser",
    "auth_method": "passkey",
    "user_agent": "string",
    "ip": "string",
    "current": false,
    "created_at": "2026-10-22T13:05:00Z",
    "last_seen_at": "2026-10-22T13:05:00Z",
    "expires_at": "2026-10-22T13:05:00Z"
  },
  "access_token": "string",
  "csrf_token": "string",
  "user": {
    "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
    "email": "stewart@primecarers.co.uk",
    "name": "Stewart Webb",
    "avatar_url": null,
    "github_login": "stewartwebb",
    "has_passkey": true,
    "is_platform_admin": true,
    "default_workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
    "created_at": "2026-10-22T09:00:00Z"
  },
  "new_user": false
}
```

## Start signing in with a passkey

```http
POST /v1/auth/passkeys/login/begin
```

- **Operation:** `beginPasskeyLogin`
- **Scopes:** none (public)
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [PasskeyLoginBeginRequest](/docs/api/objects/passkey-login-begin-request/)

| Field | Type | Description |
| --- | --- | --- |
| `email` | string (email) | Optional; omit for discoverable credentials (passkey autofill). |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [PasskeyChallenge](/docs/api/objects/passkey-challenge/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/passkeys/login/begin \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "ada@example.co.uk"
}'
```

**Example response**

```json
{
  "challenge_id": "string",
  "expires_at": "2026-10-22T13:05:00Z",
  "public_key_creation": {
    "challenge": "string",
    "rp": {
      "id": "guv.dev",
      "name": "Guv"
    },
    "user": {
      "id": "string",
      "name": "string",
      "displayName": "string"
    },
    "pubKeyCredParams": [
      {}
    ],
    "timeout": 1,
    "authenticatorSelection": {},
    "attestation": "string",
    "excludeCredentials": [
      {}
    ]
  },
  "public_key_request": {
    "challenge": "string",
    "rpId": "string",
    "timeout": 1,
    "userVerification": "required",
    "allowCredentials": [
      {}
    ]
  }
}
```

## Finish signing in with a passkey

```http
POST /v1/auth/passkeys/login/finish
```

- **Operation:** `finishPasskeyLogin`
- **Scopes:** none (public)
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [PasskeyFinishRequest](/docs/api/objects/passkey-finish-request/)

| Field | Type | Description |
| --- | --- | --- |
| `challenge_id` * | string |  |
| `credential` * | [WebAuthnCredential](/docs/api/objects/web-authn-credential/) |  |
| `name` | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [LoginResponse](/docs/api/objects/login-response/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/passkeys/login/finish \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "challenge_id": "string",
  "credential": {
    "id": "string",
    "rawId": "string",
    "type": "public-key",
    "response": {},
    "authenticatorAttachment": "string",
    "clientExtensionResults": {}
  },
  "name": "string"
}'
```

**Example response**

```json
{
  "session": {
    "id": "ses_01jbx2m9k7q4r8t5v6w3y0z1pr",
    "kind": "browser",
    "auth_method": "passkey",
    "user_agent": "string",
    "ip": "string",
    "current": false,
    "created_at": "2026-10-22T13:05:00Z",
    "last_seen_at": "2026-10-22T13:05:00Z",
    "expires_at": "2026-10-22T13:05:00Z"
  },
  "access_token": "string",
  "csrf_token": "string",
  "user": {
    "id": "usr_01jbx2m9k7q4r8t5v6w3y0z1us",
    "email": "stewart@primecarers.co.uk",
    "name": "Stewart Webb",
    "avatar_url": null,
    "github_login": "stewartwebb",
    "has_passkey": true,
    "is_platform_admin": true,
    "default_workspace_id": "ws_01jbx2m9k7q4r8t5v6w3y0z1ws",
    "created_at": "2026-10-22T09:00:00Z"
  },
  "new_user": false
}
```

## Start a passkey assertion for a sensitive action

```http
POST /v1/auth/step-up/begin
```

Used before approving an agent request, revealing credentials, deleting resources, raising a spend cap or changing the kill switch. The resulting `PasskeyAssertion` is sent with that action.

- **Operation:** `beginStepUp`
- **Scopes:** any signed-in principal
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [StepUpBeginRequest](/docs/api/objects/step-up-begin-request/)

| Field | Type | Description |
| --- | --- | --- |
| `purpose` * | `approval`, `kill_switch`, `reveal_credentials`, `delete_resource`, `raise_spend_cap` |  |
| `subject_id` | string | The approval, database or other resource the assertion is bound to. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [PasskeyChallenge](/docs/api/objects/passkey-challenge/) |

**Errors:** `400`, `401`, `403`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/step-up/begin \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "purpose": "approval",
  "subject_id": "apr_01jbx2m9k7q4r8t5v6w3y0z1ap"
}'
```

**Example response**

```json
{
  "challenge_id": "string",
  "expires_at": "2026-10-22T13:05:00Z",
  "public_key_creation": {
    "challenge": "string",
    "rp": {
      "id": "guv.dev",
      "name": "Guv"
    },
    "user": {
      "id": "string",
      "name": "string",
      "displayName": "string"
    },
    "pubKeyCredParams": [
      {}
    ],
    "timeout": 1,
    "authenticatorSelection": {},
    "attestation": "string",
    "excludeCredentials": [
      {}
    ]
  },
  "public_key_request": {
    "challenge": "string",
    "rpId": "string",
    "timeout": 1,
    "userVerification": "required",
    "allowCredentials": [
      {}
    ]
  }
}
```

## Start the CLI device flow (RFC 8628)

```http
POST /v1/auth/device/code
```

- **Operation:** `requestDeviceCode`
- **Scopes:** none (public)
- **CLI:** `guv login`
- **Idempotency-Key:** optional

**Request body** (`application/json`, required): [DeviceCodeRequest](/docs/api/objects/device-code-request/)

| Field | Type | Description |
| --- | --- | --- |
| `client_id` * | string |  |
| `scope` | string | Space-separated scopes. Defaults to the user's full role. |
| `device_name` | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [DeviceCodeResponse](/docs/api/objects/device-code-response/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/device/code \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "client_id": "guv-cli",
  "scope": "string",
  "device_name": "stewart-mbp"
}'
```

**Example response**

```json
{
  "device_code": "string",
  "user_code": "BDWP-HQPK",
  "verification_uri": "https://guv.dev/device",
  "verification_uri_complete": "https://guv.dev/device?code=BDWP-HQPK",
  "expires_in": 600,
  "interval": 5
}
```

## Show which device is asking to sign in

```http
GET /v1/auth/device
```

- **Operation:** `lookupDeviceCode`
- **Scopes:** any signed-in principal

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `user_code` * | query | string | Code shown in the terminal. |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [DeviceLookup](/docs/api/objects/device-lookup/) |

**Errors:** `400`, `401`, `403`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl https://api.guv.dev/v1/auth/device \
  -H "Authorization: Bearer $GUV_TOKEN"
```

**Example response**

```json
{
  "user_code": "string",
  "client_id": "string",
  "device_name": "string",
  "ip": "string",
  "requested_at": "2026-10-22T13:05:00Z",
  "scopes": [
    "deploy:staging"
  ]
}
```

## Approve or deny a CLI sign-in (signed-in user)

```http
POST /v1/auth/device/decision
```

- **Operation:** `decideDeviceCode`
- **Scopes:** any signed-in principal
- **Idempotency-Key:** required

**Request body** (`application/json`, required): [DeviceDecisionRequest](/docs/api/objects/device-decision-request/)

| Field | Type | Description |
| --- | --- | --- |
| `user_code` * | string |  |
| `decision` * | `approve`, `deny` |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `204` No Content | Done. No body. | none |

**Errors:** `400`, `401`, `403`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/device/decision \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "user_code": "string",
  "decision": "approve"
}'
```

## Exchange a device code, authorisation code or refresh token for an access token

```http
POST /v1/auth/token
```

Errors use problem+json with the OAuth `error` field set (`authorization_pending`, `slow_down`, `expired_token`, `access_denied`, `invalid_grant`).

- **Operation:** `issueToken`
- **Scopes:** none (public)

**Request body** (`application/json`, required): [TokenRequest](/docs/api/objects/token-request/)

| Field | Type | Description |
| --- | --- | --- |
| `grant_type` * | `urn:ietf:params:oauth:grant-type:device_code`, `refresh_token`, `authorization_code` |  |
| `client_id` * | string |  |
| `device_code` | string |  |
| `refresh_token` | string |  |
| `code` | string |  |
| `code_verifier` | string |  |
| `redirect_uri` | string |  |

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `200` OK | OK. | [TokenResponse](/docs/api/objects/token-response/) |

**Errors:** `400`, `401`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/token \
  -H "Authorization: Bearer $GUV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
  "client_id": "string",
  "device_code": "string",
  "refresh_token": "string",
  "code": "string",
  "code_verifier": "string",
  "redirect_uri": "string"
}'
```

**Example response**

```json
{
  "access_token": "string",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "string",
  "scope": "string"
}
```

## Sign out the current session

```http
POST /v1/auth/logout
```

- **Operation:** `logout`
- **Scopes:** any signed-in principal
- **CLI:** `guv logout`
- **Idempotency-Key:** optional

**Responses**

| Status | Meaning | Body |
| --- | --- | --- |
| `204` No Content | Done. No body. | none |

**Errors:** `400`, `401`, `403`, `409`, `422`, `429`, `500`, as [problem+json](/errors/) with a stable `code`.

**Example**

```bash
curl -X POST https://api.guv.dev/v1/auth/logout \
  -H "Authorization: Bearer $GUV_TOKEN"
```
