# Device Authorization

> OAuth 2.0 device authorization flow (RFC 8628) for signing in from CLIs, IDEs, and other devices without a browser on the same machine.

**URL:** https://inference.sh/docs/api/rest/device-auth
**Last updated:** 2026-11-08

---

OAuth 2.0 device authorization flow (RFC 8628) for signing in from CLIs, IDEs, and other devices without a browser on the same machine.

The **belt** and **infsh** CLIs use this flow by default when you run `belt login` or `infsh login` in a terminal. You can also call the endpoints directly — or point any off-the-shelf OAuth 2.0 client library at our standard endpoints (see [Standard OAuth endpoints](#standard-oauth-endpoints-rfc-8628)).

No API key is required to **start** or **poll** a device authorization. Looking up a user code and approving a request require the person's own sign-in (a logged-in web app session in the browser).

→ [Authentication](/docs/api/authentication) · [Magic link sign-in](/docs/api/rest/magic-link) · [CLI setup](/docs/extend/cli-setup#login)

---

## Credential kinds

When you start device authorization, an optional `token_kind` field selects what the **first successful poll** returns after browser approval:

| `token_kind` | Returned field | Behavior |
|--------------|----------------|----------|
| `session` | `session_token` | Revocable CLI session (30-day expiry by default; configurable at approval). Acts as the signed-in user, supports switching workspaces with `X-Team-ID`, optional [API key scopes](#approve-device-authorization) at approval, and appears in [settings → personal → sessions](https://app.inference.sh/settings/sessions). Send as `Authorization: Bearer …` on API calls. |
| *(omit or `api_key`)* | `api_key` | Legacy 30-day device-scoped API key (`CLI`, `source: device-auth`). Still returned for older CLIs that send no request body. |

New CLIs should request `token_kind: "session"`. The API key path remains for backward compatibility until older CLIs are retired.

---

## Flow overview

```mermaid
sequenceDiagram
    participant CLI as CLI or client
    participant API as api.inference.sh
    participant Browser as Browser (logged in)

    CLI->>API: POST /device/auth {"token_kind":"session"}
    API-->>CLI: user_code, device_code, approve_url, interval
    CLI->>Browser: Open approve_url (optional)
    Browser->>API: POST /device/auth/approve (session cookie)
    loop Every interval seconds
        CLI->>API: GET /device/auth/poll/{device_code}
        API-->>CLI: status pending | slow_down | approved | denied | expired
    end
    Note over API,CLI: First approved poll mints and returns credential (one-shot)
    API-->>CLI: session_token or api_key
```

1. Your client calls `POST /device/auth` and receives a short **user code** and a secret **device code**.
2. The user opens **approve_url** in a browser (or enters the user code at the web app's device-auth page) and approves while signed in. Approval records the authorization only: **no credential is created or stored yet**.
3. Your client polls `GET /device/auth/poll/{device_code}` every **interval** seconds until the status is `approved`, `denied`, or `expired`.
4. On the **first** poll where `status` is `approved`, the API mints and returns a credential (one-shot delivery):
   - **`session_token`** when the flow was started with `token_kind: "session"` — a revocable session you send as `Authorization: Bearer …`. Supports `X-Team-ID` like a web session. The session records the **polling device's** IP and user agent (not the approving browser's).
   - **`api_key`** when no `token_kind` was sent (legacy) — a 30-day CLI key named `CLI` with `source: device-auth`.

Browser approval only records authorization (user, workspace, scopes). The credential is minted at poll time and is not stored in the challenge record. After a successful delivery, a later poll of the same `device_code` gets no credential: `GET /device/auth/poll` answers `status: expired`, and `POST /oauth/token` answers `invalid_grant`.

Codes expire after **300 seconds** (5 minutes), including approved-but-never-collected authorizations. Each code starts with a poll interval of **3** seconds; polling faster raises it (see [Polling and slow_down](#polling-and-slow_down)).

When the device starts the flow, the API records who started it: the client and version (from the user agent's product token, for example `belt 1.22.1`), the OS, the IP address, and the location from a geo lookup. The approval page shows this record, never anything the link carries.

---

## Standard OAuth endpoints (RFC 8628)

For third-party clients we recommend the standards-compliant endpoints — any OAuth 2.0 library that supports the device authorization grant works unmodified. Endpoints are discoverable via [`/.well-known/oauth-authorization-server`](https://api.inference.sh/.well-known/oauth-authorization-server) (RFC 8414).

### Start: `POST /oauth/device_authorization`

**Authentication:** None. Always mints a session token.

```bash
curl -X POST https://api.inference.sh/oauth/device_authorization
```

```json
{
  "device_code": "368bcb…",
  "user_code": "4XQA-HTM9",
  "verification_uri": "https://app.inference.sh/auth/device",
  "verification_uri_complete": "https://app.inference.sh/auth/device?code=4XQA-HTM9",
  "expires_in": 300,
  "interval": 3
}
```

#### Scope on the start request

The optional `scope` parameter (space-separated, RFC 8628 3.1) bounds what the approval may grant:

```bash
curl -X POST https://api.inference.sh/oauth/device_authorization \
  -d "scope=apps:read apps:execute"
```

| `scope` | Result |
|---------|--------|
| Omitted or empty | The device asks for full access; the approver picks what to grant |
| Names known resource scopes | The approval grants at most these. Protocol and unknown names next to them are ignored |
| Names only protocol scopes (for example `openid`), or only unknown scopes | **400** `invalid_scope` |

The approval page starts from the requested scopes. An approval that picks a scope the device did not ask for fails with **400** `invalid_scope`. The legacy `POST /device/auth` takes no scope: its devices ask for full access.

Too many starts from one IP (30 per 15 minutes, shared with `POST /device/auth`) return **429** `temporarily_unavailable` with a `Retry-After` header.

### Exchange: `POST /oauth/token`

Poll the token endpoint with the device code grant:

```bash
curl -X POST https://api.inference.sh/oauth/token \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "device_code=DEVICE_CODE_HERE"
```

Until the credential is delivered, the endpoint returns standard RFC 8628 errors (HTTP 400):

| Error | Meaning |
|-------|---------|
| `authorization_pending` | User has not approved yet — keep polling |
| `slow_down` | Still pending, and this poll came too soon. Add 5 seconds to your interval (see [Polling and slow_down](#polling-and-slow_down)) |
| `access_denied` | User denied the request |
| `expired_token` | Code expired before the credential was collected |
| `invalid_grant` | Unknown device code, or the credential was already delivered (one-shot) |

On approval:

```json
{
  "access_token": "0zdzfx…",
  "token_type": "Bearer",
  "team_id": "team_abc123"
}
```

`team_id` is an extension member identifying the workspace the user selected at approval. Send the token as `Authorization: Bearer …` on subsequent requests.

### Revoke a credential (RFC 7009)

`POST /oauth/revoke` — possession of a token is the authority to revoke it. Works for session tokens and API keys. Unknown tokens still return `200` (per the RFC, so validity can't be probed).

```bash
curl -X POST https://api.inference.sh/oauth/revoke -d "token=TOKEN_HERE"
```

---

## Authorization code flow (MCP and third-party clients)

MCP clients (Claude, Cursor, Windsurf, and others) can call [`https://api.inference.sh/mcp`](/docs/connectors/mcp-server) with OAuth access tokens instead of workspace API keys. The authorization server advertises:

- [`GET /.well-known/oauth-authorization-server`](https://api.inference.sh/.well-known/oauth-authorization-server) (RFC 8414)
- [`GET /.well-known/oauth-protected-resource`](https://api.inference.sh/.well-known/oauth-protected-resource) (RFC 9728)

Supported scopes are `apps:read` and `apps:execute`. A request that names no recognised scope is granted both (`apps:read apps:execute`), never full access. When the client registered a `scope` at `POST /oauth/register`, the grant stays within it, and a request entirely outside it fails with **400** `invalid_scope`. Public clients must use PKCE (`code_challenge_method=S256`). The server supports [Client ID Metadata Documents (CIMD)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/991) (`client_id_metadata_document_supported: true`) and [Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) at `POST /oauth/register`.

Typical flow:

1. `GET /oauth/authorize` with `client_id`, `redirect_uri`, `scope`, `state`, PKCE parameters, and optional `team_id` (workspace hint for the consent UI). Unauthenticated users are redirected to the web app to sign in. Signed-in users are always sent to the **consent screen** — the authorize endpoint never issues an authorization code directly, even if the user approved the same client before or the request includes `prompt=none`. Clients that stay signed in should use `grant_type=refresh_token` instead of repeating authorize.
2. The API redirects to the web app consent page at [`/auth/consent`](https://app.inference.sh/auth/consent), forwarding the authorize query string (including optional `team_id`). On that screen the user picks a **workspace** and approves scopes. When `team_id` names a workspace the signed-in user belongs to, the workspace switcher starts on that team; otherwise it defaults to the browser session's active workspace, or the user's first workspace. Changing the switcher before **Authorize** sets the `team_id` sent on approval — the authorization code and tokens are issued for that workspace, not silently for whichever workspace happened to be active in the app shell. Approval is recorded with `POST /oauth/authorize/approve` (the person's own sign-in) or denied with `POST /oauth/authorize/deny`.
3. After approval, the client exchanges the authorization code at `POST /oauth/token` with `grant_type=authorization_code` and the PKCE verifier.
4. Send the JWT `access_token` as `Authorization: Bearer …` on MCP requests (short-lived; refresh with `grant_type=refresh_token`). Tokens are issued for the workspace chosen at consent. An OAuth access token in the `?access_token=` query parameter is refused: send it in the `Authorization` header only.

### Token lifetimes

Every `POST /oauth/token` response states how long both tokens last, so a client can plan its refreshes instead of finding out by being refused:

```json
{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "rt_…",
  "refresh_token_expires_in": 2592000,
  "scope": "apps:read apps:execute"
}
```

| Token | Lifetime | Notes |
|-------|----------|-------|
| `access_token` | **10 minutes** (`expires_in: 600`) | A JWT. Renew it with `grant_type=refresh_token`; a request with an expired one gets **401**. |
| `refresh_token` | **30 days unused** (`refresh_token_expires_in: 2592000`) | Single use. Each refresh retires it and issues a new pair with a full 30-day term again. |

The refresh token's term is an **idle** limit, not an absolute one: a client that refreshes at least once every 30 days keeps the same authorization indefinitely and never sends the user back through consent. There is no cap on how long a chain of refreshes may run. The authorization ends only when:

- the refresh token goes **unused for 30 days**: the next refresh answers **400** `invalid_grant` ("Refresh token expired") and the client starts at `GET /oauth/authorize` again;
- the user **revokes** the client at [settings → personal → authorized apps](https://app.inference.sh/#settings/connected-apps) or `POST /oauth/connected-apps/revoke`;
- a refresh token is **presented twice**: the second request gets `invalid_grant`, since the first already rotated it. Store the new pair before you discard the old one.

`refresh_token_expires_in` is not part of RFC 6749 (which leaves refresh-token lifetime to the server and unstated); it is the field name GitHub and other providers use for the same number. A client that does not know it can ignore it.

### Consent UI: `GET /oauth/authorize/info`

**Authentication:** The person's own sign-in (browser session or `belt login` session): these take `user:read` or `user:write`, which only it holds. API keys and OAuth access tokens get **403** `requires_sign_in`.

The consent page loads client metadata before the user approves or denies:

| Query | Required | Description |
|-------|----------|-------------|
| `client_id` | Yes | OAuth client id (opaque `dyn_…` id, or HTTPS URL for CIMD) |
| `redirect_uri` | No | Redirect URI from the authorize request (used for verification hints) |
| `scope` | No | Space-separated scopes |

**Response** (`OAuthAuthorizeInfoResponse`):

| Field | Description |
|-------|-------------|
| `client_name` | Display name shown on the consent screen |
| `client_type` | `metadata_document`, `registered`, etc. |
| `origin` | Hostname derived from the client id or redirect URI |
| `verified` | Whether the platform treats the client as a known publisher |
| `scopes` | Parsed scope list with labels |
| `redirect_host` | Hostname of `redirect_uri` when provided |

Approve or deny with `POST /oauth/authorize/approve` and `POST /oauth/authorize/deny`. Both endpoints require the person's own sign-in: the browser session (cookie after magic link or SSO) or a `belt login` session. Both take `user:write`, which only that sign-in holds: API keys (personal and workspace), OAuth access tokens, and sessions bound to one place (for example an embedded workspace frame) get **403** `requires_sign_in`. None of them can approve an OAuth client or a device code, even for their own workspace. An approval mints credentials that renew on their own (CLI sessions, OAuth refresh tokens) or roam across workspaces (device-auth keys), so only a session may grant one.

**Approve body** (`OAuthApproveRequest`): `client_id`, `redirect_uri`, `code_challenge`, `state`, `scope`, and optional `team_id` (workspace picked on the consent form; omit to use the session's active workspace). An empty `scope` grants `apps:read apps:execute`. Requested scopes cannot exceed what the approving session holds — see [Scope ceiling at approval](#scope-ceiling-at-approval).

### Workspace choice at approval

When the approver is the person's own sign-in (the only credential that may call these routes), `team_id` selects the workspace for the minted OAuth tokens or device credential. The API checks membership with the same rules as `X-Team-ID` elsewhere: the team must exist and the signed-in person must be allowed to act as it (personal workspace, org membership, service-account rules, and so on).

| `team_id` in approve body | Result |
|---------------------------|--------|
| Omitted | Uses the session's active workspace |
| Same as the session's active workspace | Allowed |
| Another workspace you belong to | Allowed |
| A workspace you cannot act as | **403** `forbidden` |

The same `team_id` rules apply to `POST /device/auth/approve`.

### Scope ceiling at approval

Approvals follow the same scope ceiling as [`POST /apikeys`](/docs/api/rest/api-keys#create-api-key): the minted credential cannot be stronger than the approver.

- An **unrestricted** approving session (no scope limit on the session itself) may grant any requested scopes. Omitting `scopes` on device approve means full access for the new credential. An OAuth grant is never full access: an empty `scope` on OAuth approve grants `apps:read apps:execute`.
- A **scoped** approving session (for example a `belt login` session approved with a subset of permissions) may grant only scopes it already holds. A requested scope outside that set returns **403** `forbidden` (`This sign-in cannot grant a scope it does not have`). Omitting `scopes` on device approve grants **the approver's scopes**, not full access.

Resource-level scopes on the approver satisfy action-level requests the same way as on key creation (for example an approver with `apps` may grant `apps:read`).

### Verified clients on the consent screen

The API marks some OAuth clients as **verified** so users can distinguish first-party and partner integrations from arbitrary Dynamic Client Registration clients.

| Client identity | `verified` | `client_name` on consent |
|-----------------|------------|---------------------------|
| CIMD whose URL host is `claude.ai`, `anthropic.com`, `cursor.com`, or `windsurf.com` | `true` | Canonical partner name (`Claude`, `Cursor`, …) |
| CIMD hosted on `inference.sh` (for example `https://inference.sh/oauth/clients/apple.json`) | `true` | `client_name` from the metadata document (for example `inference.sh for Apple`) |
| Opaque client id from `POST /oauth/register` whose `redirect_uri` host is `claude.ai`, `anthropic.com`, `cursor.com`, or `windsurf.com` | `true` | Canonical partner name |
| Any other opaque client id from `POST /oauth/register` | `false` | Name supplied at registration |
| CIMD on any other host | `false` | Name from the fetched metadata document |

Verification for **first-party** documents is based on the **`client_id` URL hostname only**. A `redirect_uri` that points at `inference.sh` does **not** verify a registered client; only the partner hosts above verify by redirect. Hostname lookalikes (for example `inference.sh.example.com`) are not treated as `inference.sh`.

First-party metadata documents are public JSON files under `https://inference.sh/oauth/clients/`. Example:

```
GET https://inference.sh/oauth/clients/apple.json
```

```json
{
  "client_id": "https://inference.sh/oauth/clients/apple.json",
  "client_name": "inference.sh for Apple",
  "redirect_uris": ["https://inference.sh/oauth/callback/apple"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

### Manage authorized OAuth clients

**Authentication:** The person's own sign-in (browser session or `belt login` session): these take `user:read` or `user:write`, which only it holds. API keys and OAuth access tokens get **403** `requires_sign_in`.

| Endpoint | Description |
|----------|-------------|
| `GET /oauth/connected-apps` | OAuth clients you have authorized (active refresh grants) |
| `POST /oauth/connected-apps/revoke` | Body `{ "client_id": "…" }` — revokes refresh tokens for that client |

The web app lists these under [settings → personal → authorized apps](https://app.inference.sh/#settings/connected-apps). Each grant is shown under the identity it was approved for: a registered client approved through a partner's callback keeps its verified badge and name in this list.

---

## Start device authorization

`POST /device/auth`

**Authentication:** None.

### Request (optional body)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `token_kind` | string | No | `session` for a revocable CLI session; omit for a legacy API key |

```json
{
  "token_kind": "session"
}
```

An empty body or omitted `token_kind` keeps legacy behavior (API key on the first approved poll).

### Response

| Field | Type | Description |
|-------|------|-------------|
| `user_code` | string | Short code shown to the user (format `XXXX-XXXX`) |
| `device_code` | string | Secret code used only for polling (do not display to the user) |
| `approve_url` | string | Web app URL to open for one-click approval |
| `poll_url` | string | Convenience URL (web app host); clients should poll the API path below |
| `expires_in` | number | Seconds until the code expires (`300`) |
| `interval` | number | Recommended seconds between poll requests (`3`) |

**Example:**

```bash
curl -X POST https://api.inference.sh/device/auth \
  -H "Content-Type: application/json" \
  -d '{"token_kind":"session"}'
```

```json
{
  "user_code": "ABCD-2345",
  "device_code": "01HXYZ…",
  "approve_url": "https://app.inference.sh/auth/device?code=ABCD-2345&utm_source=cli&utm_medium=app",
  "poll_url": "https://app.inference.sh/device/auth/poll/01HXYZ…",
  "expires_in": 300,
  "interval": 3
}
```

Too many starts from one IP (30 per 15 minutes, shared with `POST /oauth/device_authorization`) return **429** `rate_limited` with a `Retry-After` header.

---

## Poll for approval

`GET /device/auth/poll/{device_code}`

**Authentication:** None.

Poll until `status` is terminal (`approved`, `denied`, or `expired`). Wait at least `interval` seconds between requests. `slow_down` is not terminal: keep polling at the new `interval`.

The credential is minted by — and delivered only to — the **first** poll that sees `approved`. Subsequent polls return `expired` with no credential. If you miss the response, restart the flow.

### Response

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `pending`, `slow_down`, `approved`, `denied`, or `expired` |
| `interval` | number | Present when `status` is `slow_down`: the seconds between polls this code now requires |
| `session_token` | string | Present when `status` is `approved` and the flow used `token_kind: "session"` |
| `api_key` | string | Present when `status` is `approved` and the flow used the legacy API-key path (starts with `1nfsh-`) |
| `team_id` | string | Workspace context for the login (when approved) |

Only one credential field is set per flow. While `status` is `pending` or `slow_down`, both are omitted. An unknown `device_code` answers `status: expired`.

**One-shot delivery:** The first poll that observes `status: approved` atomically mints the credential and returns it. Later polls for the same `device_code` return `status: expired`. If the user approves in the browser but the client stops polling before the code expires, the authorization is lost when the 300-second window ends.

### Polling and slow_down

Both poll endpoints follow RFC 8628 3.5. Each code holds its device to an interval, starting at 3 seconds:

- A poll is admitted when the code's previous poll was at least the interval ago, less 1 second of leeway for network delay. Of two concurrent polls, one is admitted.
- A poll that comes sooner is answered `slow_down` (`status: slow_down` with the new `interval` here, error `slow_down` on `POST /oauth/token`), and the code's interval grows by 5 seconds for that poll and every later one.
- `slow_down` only replaces `pending`. A code that was approved, denied, or expired answers that however soon it is polled.

A client that ignores `slow_down` and keeps polling early gets `slow_down` on every poll until the code leaves pending, then gets its terminal answer. Older CLIs that treat any non-terminal status as pending keep working.

**Example:**

```bash
curl "https://api.inference.sh/device/auth/poll/DEVICE_CODE_HERE"
```

```json
{
  "status": "approved",
  "session_token": "0zdzfx…",
  "team_id": "team_abc123"
}
```

---

## Using a session token

After approval with `token_kind: "session"`, store the `session_token` securely and send it on API requests:

```bash
curl https://api.inference.sh/apps/my \
  -H "Authorization: Bearer SESSION_TOKEN_HERE"
```

Session tokens are neither API keys (`1nfsh-…`) nor JWTs. The API resolves them as user sessions via the `Authorization` header.

**Switching workspaces:** Send `X-Team-ID` to act as another workspace you belong to, with the same rules as a browser session. See [Authentication: workspace context](/docs/api/authentication#workspace-context).

**Revocation:** CLI sessions appear in [settings → personal → sessions](https://app.inference.sh/settings/sessions) (`auth_method: device-auth`) with the polling device's IP and user agent. The belt and infsh CLIs send an [RFC 7231](https://www.rfc-editor.org/rfc/rfc7231#section-5.5.3) product token (`belt/x.y.z` or legacy `inference-cli/x.y.z`), which the API parses into client name and version for the session record. Revoke sessions from the web app or via the [sign-in sessions API](#sign-in-sessions) below.

### Sliding lifetime

CLI session tokens use the same **sliding idle window** as browser sign-in cookies:

- On each authenticated API request (every `belt` command that hits the API counts), if less than half of the current window remains, the server moves `expires_at` forward to **now + 7 days**.
- The token string **does not rotate**. The CLI keeps sending the value stored in `~/.inferencesh/config.json` (or under `BELT_CONFIG_DIR`); you do not need to log in again just because the server extended the session.
- Sessions stay alive while you use them regularly; idle sessions still expire when `expires_at` passes.

**Fixed TTL:** When approval sets `expires_in` to a lifetime **shorter than 7 days** (for example a one-hour session), that expiry is intentional and is **not** extended on use — for bearer tokens or session cookies.

---

## Sign-in sessions

Manage browser and CLI **authentication sessions** (distinct from [app sessions](/docs/api/rest/sessions) used for stateful `POST /run`).

Routes are under `/auth/` and require an authenticated session or bearer token.

| Endpoint | Scope |
|----------|-------|
| `GET /auth/sessions` | `user:read` |
| `DELETE /auth/sessions/{id}` | `user:write` |
| `POST /auth/sessions/revoke-all` | `user:write` |

### List sessions

`GET /auth/sessions`

Returns active sign-in sessions for the current user. The session making the request is marked `current: true`.

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Session ID |
| `created_at` | string | ISO timestamp |
| `expires_at` | string | ISO timestamp |
| `ip` | string | Client IP |
| `browser` | string | Browser or client name |
| `auth_method` | string | `magic-link`, `device-auth`, `api_key`, etc. |
| `scopes` | string[] | Active API key scopes when `auth_method` is `api_key` |
| `current` | boolean | Whether this is the requesting session |

```bash
curl https://api.inference.sh/auth/sessions \
  -H "Authorization: Bearer inf_your_session_token"
```

### Revoke one session

`DELETE /auth/sessions/{id}`

Deletes a specific session. Use another valid session or token to call this endpoint.

### Revoke all other sessions

`POST /auth/sessions/revoke-all`

Deletes every session except the one making the request.

You can also revoke bearer tokens with `POST /oauth/revoke` ([RFC 7009](#revoke-a-credential-rfc-7009)).

---

## Check user code (approval page)

`GET /device/auth/code/{code}`

**Authentication:** The person's own sign-in (browser session or `belt login` session). It takes `user:read`: API keys and OAuth access tokens get **403** `requires_sign_in`. Signed-out callers get **401**.

Used by the web app's device-auth UI to describe a user code before approval. The answer shows where the device that started it is, so it is only for a signed-in person, and each lookup spends one [code attempt](#attempt-limits). Accepts codes with or without the dash (`ABCD2345` or `ABCD-2345`).

Only a pending code within its window is described. An unknown, used, denied, or expired code answers the same way (`valid: false`, `status: expired`), so a guessed code reveals nothing.

### Response

`DeviceAuthCodeInfo`:

| Field | Type | Description |
|-------|------|-------------|
| `user_code` | string | Normalized code |
| `valid` | boolean | Whether the code can still be approved |
| `status` | string | `pending` or `expired` |
| `expires_at` | string | ISO timestamp (pending codes) |
| `kind` | string | `login` (a device sign-in) or `admin_elevation` (a `belt admin elevate` request) |
| `device` | object | The device that started the code, as recorded at the start (`DeviceAuthDeviceInfo`): `client`, `client_version`, `os`, `ip`, `city`, `country`, `created_at` |
| `proximity` | string | Where the code was started relative to this request: `same_network`, `same_country`, `different`, or `unknown` (see [Approving from another network](#approving-from-another-network)) |
| `scopes` | string[] | Scopes the device asked for at the start; empty when it named none |

---

## Approve device authorization

`POST /device/auth/approve`

**Authentication:** The person's own sign-in: the browser session (cookie after SSO or magic link) or a `belt login` session. It takes `user:write`: API keys and OAuth access tokens get **403** `requires_sign_in`. Guests get **401**.

Approval records the authorization only (signed-in user, workspace, optional scopes, and optional credential TTL). It does **not** return a credential, so the client must keep polling. The `token_kind` from initiation determines what the **first** successful poll mints:

- **`token_kind: "session"`** — revocable CLI session (30-day expiry by default, or `expires_in` from approval).
- **Legacy (no `token_kind`)** — **CLI** API key (30-day expiry by default, or `expires_in` from approval).

Pass an explicit `scopes` list to restrict what the minted credential can do. Scope limits on the **minted** credential are enforced like [API keys](/docs/api/authentication). The **approving** session cannot grant more than it holds — see [Scope ceiling at approval](#scope-ceiling-at-approval).

Nothing secret is stored server-side at approval; a minted session records the polling device's own IP and user agent.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `code` | string | Yes | User code from `POST /device/auth` |
| `team_id` | string | No | Workspace for the minted credential. Omit to use the approver's active workspace. Only the person's own sign-in (browser or `belt login` session) may call this endpoint; see [Workspace choice at approval](#workspace-choice-at-approval). |
| `scopes` | string[] | No | Permission scopes for the minted credential (session or API key). Omit for full access when the approver is unrestricted; when the approver is scoped, omit to grant the approver's scopes only. |
| `expires_in` | number | No | Credential lifetime in seconds (default `2592000` — 30 days). Capped at one year. |
| `typed_code` | string | Unless same network | The user code as the person typed it from the device's screen. Required unless the code's `proximity` is `same_network`; see [Approving from another network](#approving-from-another-network). |

```json
{
  "code": "ABCD-2345",
  "scopes": ["apps:execute", "billing:read"],
  "expires_in": 7200
}
```

### Response

```json
{
  "success": true,
  "message": "Device authorized successfully"
}
```

Errors include `invalid_code` when the code is unknown or no longer pending (used, denied, or expired), and `already_processed` when another approval took it first; `code_confirmation_required` when `typed_code` is missing or does not match; `invalid_scope` when `scopes` names a scope the device did not ask for at the start; `requires_sign_in` for any credential that is not the person's own sign-in; `forbidden` when `team_id` names a workspace this sign-in may not choose or when `scopes` includes a permission the approver does not hold; **429** `rate_limited` when the [attempt limit](#attempt-limits) is spent.

### Approving from another network

The API compares where the code was started with where the approval comes from, using the IP address and geo lookup recorded at each end:

| `proximity` | Meaning |
|-------------|---------|
| `same_network` | Same IPv4 /24 or IPv6 /64 |
| `same_country` | Another network in the same country |
| `different` | Another country |
| `unknown` | Another network, and a geo lookup could not tell the country on one side |

Only `same_network` approves with one click. For any other proximity, the approval must carry `typed_code`, the code typed from the device's screen, matching the user code. The link the person opened already carries the code, so following it proves nothing about who holds the device (RFC 8628 5.4, remote phishing). The same country is not enough: a VPN exit in the victim's country puts an attacker's device there. Without a matching `typed_code` the API answers **400** `code_confirmation_required`.

On the approval page, a sign-in started away from the browser's network shows:

- A warning: started from another network, from another country (with its location), or not confirmed to have started near you, followed by "if someone sent you this link, don't approve it."
- A **started from** panel with the client and version, OS, location, IP address, and how long ago it started.
- A field to type the code shown on the terminal. **Authorize** stays disabled until a complete code is entered, and a wrong code shows "that code doesn't match the one on your terminal".

On the same network the page shows the **started from** panel without the warning or the field.

### Attempt limits

Code lookups (`GET /device/auth/code/{code}`) and approvals (`POST /device/auth/approve`) each spend one attempt from two budgets:

- **20 per signed-in person** per 15 minutes
- **60 per IP address** per 15 minutes, across accounts. An IPv6 address counts by its /64.

When either budget is spent, the API answers **429** `rate_limited` with a `Retry-After` header. The limit fails closed: if the limiter cannot answer, the attempt is refused.

### Web app approval UI

When approving from [app.inference.sh/auth/device](https://app.inference.sh/auth/device), the consent form lets you pick **permissions** (scopes) and **session expiration** before authorizing.

The permissions start at the **belt login** preset (`standard` plus `approvals:write`). Under **custom**, the workspace permissions are listed by group, and your account's own permissions follow in a separate **your account** section, grouped the same way: api keys, user, billing (`billing:write`) and approvals, plus admin for staff. These are the [sign-in-only scopes](/docs/api/rest/api-keys#sign-in-only-scopes): a login can hold them, an API key cannot. The page reads them from `account_scopes` and `account_groups` on [`GET /scopes`](/docs/api/rest/api-keys#scope-catalog). The expiration picker defaults to **1 year** and offers 1 hour through 1 year (sessions cannot be set to never expire). The selected TTL is sent as `expires_in` on this endpoint. Programmatic callers that omit `expires_in` still get the 30-day API default.

---

## CLI usage

```bash
belt login                # device flow → session token (supports belt auth switch)
belt login --key-stdin < key.txt  # API key login — team-pinned, for CI / automation
belt auth token           # mint a 15m team-pinned API key to stdout (no newline) for scripts; --long-lived prints the stored credential
belt me                   # shows auth method: session vs api key (team-pinned)
```

`belt login` stores a **session token**: it acts as you, works with `belt auth switch`, and shows up in your sessions list in settings, where it can be revoked. `belt login --key-stdin` (or `INFERENCE_API_KEY`) stores an **API key**: pinned to one workspace by design, so `belt auth switch` has no effect and will say so.

In non-interactive environments (no TTY, `CI` set, or `BELT_NON_INTERACTIVE=1`), `belt login` without `--key` prints the approval link and the user code instead of opening a browser. When the browser is on another network, the approval page asks for that code. After approving, the browser shows a new 6-digit code: run `belt login --code <6-digit code from the browser>` on the same machine. The link and codes expire after 5 minutes, and running `belt login` again starts over. For CI and scripts, pass `--key-stdin` or set `INFERENCE_API_KEY`. See [CLI setup: Login](/docs/extend/cli-setup#login). After login, use `belt auth token` in subshells: `curl -H "Authorization: Bearer $(belt auth token)" ...`

**Session-start hook:** When the belt plugin is installed for Claude Code or Codex, the SessionStart hook calls `belt plugin hook session-start`. If no credential is configured (including anonymous guest sessions), the hook opens a browser for device authorization, prints the approval URL and user code into session context, and polls for up to 45 seconds. A 6-hour cooldown prevents reopening the browser on every session (`~/.inferencesh/hook-auth-cooldown`). When already logged in, the hook prints your account email instead.

→ [CLI setup — Login](/docs/extend/cli-setup#login) · [Coding agents — SessionStart hook](/docs/extend/coding-agents#other-agent-runtimes-belt-plugin-init)

---

## Related

- [Authentication](/docs/api/authentication) — API keys and SDK setup
- [REST overview](/docs/api/rest/overview) — Base URL and response format
