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).
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 · Magic link sign-in · CLI setup
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 at approval, and appears in settings → personal → 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
1sequenceDiagram2 participant CLI as CLI or client3 participant API as api.inference.sh4 participant Browser as Browser (logged in)56 CLI->>API: POST /device/auth {"token_kind":"session"}7 API-->>CLI: user_code, device_code, approve_url, interval8 CLI->>Browser: Open approve_url (optional)9 Browser->>API: POST /device/auth/approve (session cookie)10 loop Every interval seconds11 CLI->>API: GET /device/auth/poll/{device_code}12 API-->>CLI: status pending | slow_down | approved | denied | expired13 end14 Note over API,CLI: First approved poll mints and returns credential (one-shot)15 API-->>CLI: session_token or api_key- Your client calls
POST /device/authand receives a short user code and a secret device code. - 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.
- Your client polls
GET /device/auth/poll/{device_code}every interval seconds until the status isapproved,denied, orexpired. - On the first poll where
statusisapproved, the API mints and returns a credential (one-shot delivery):session_tokenwhen the flow was started withtoken_kind: "session"— a revocable session you send asAuthorization: Bearer …. SupportsX-Team-IDlike a web session. The session records the polling device's IP and user agent (not the approving browser's).api_keywhen notoken_kindwas sent (legacy) — a 30-day CLI key namedCLIwithsource: 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).
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 (RFC 8414).
Start: POST /oauth/device_authorization
Authentication: None. Always mints a session token.
1curl -X POST https://api.inference.sh/oauth/device_authorization1{2 "device_code": "368bcb…",3 "user_code": "4XQA-HTM9",4 "verification_uri": "https://app.inference.sh/auth/device",5 "verification_uri_complete": "https://app.inference.sh/auth/device?code=4XQA-HTM9",6 "expires_in": 300,7 "interval": 38}Scope on the start request
The optional scope parameter (space-separated, RFC 8628 3.1) bounds what the approval may grant:
1curl -X POST https://api.inference.sh/oauth/device_authorization \2 -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:
1curl -X POST https://api.inference.sh/oauth/token \2 -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \3 -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) |
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:
1{2 "access_token": "0zdzfx…",3 "token_type": "Bearer",4 "team_id": "team_abc123"5}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).
1curl -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 with OAuth access tokens instead of workspace API keys. The authorization server advertises:
GET /.well-known/oauth-authorization-server(RFC 8414)GET /.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) (client_id_metadata_document_supported: true) and Dynamic Client Registration at POST /oauth/register.
Typical flow:
GET /oauth/authorizewithclient_id,redirect_uri,scope,state, PKCE parameters, and optionalteam_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 includesprompt=none. Clients that stay signed in should usegrant_type=refresh_tokeninstead of repeating authorize.- The API redirects to the web app consent page at
/auth/consent, forwarding the authorize query string (including optionalteam_id). On that screen the user picks a workspace and approves scopes. Whenteam_idnames 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 theteam_idsent 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 withPOST /oauth/authorize/approve(the person's own sign-in) or denied withPOST /oauth/authorize/deny. - After approval, the client exchanges the authorization code at
POST /oauth/tokenwithgrant_type=authorization_codeand the PKCE verifier. - Send the JWT
access_tokenasAuthorization: Bearer …on MCP requests (short-lived; refresh withgrant_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 theAuthorizationheader 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:
1{2 "access_token": "eyJ…",3 "token_type": "Bearer",4 "expires_in": 600,5 "refresh_token": "rt_…",6 "refresh_token_expires_in": 2592000,7 "scope": "apps:read apps:execute"8}| 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 atGET /oauth/authorizeagain; - the user revokes the client at settings → personal → authorized 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.
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: 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
scopeson device approve means full access for the new credential. An OAuth grant is never full access: an emptyscopeon OAuth approve grantsapps:read apps:execute. - A scoped approving session (for example a
belt loginsession approved with a subset of permissions) may grant only scopes it already holds. A requested scope outside that set returns 403forbidden(This sign-in cannot grant a scope it does not have). Omittingscopeson 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:
1GET https://inference.sh/oauth/clients/apple.json1{2 "client_id": "https://inference.sh/oauth/clients/apple.json",3 "client_name": "inference.sh for Apple",4 "redirect_uris": ["https://inference.sh/oauth/callback/apple"],5 "grant_types": ["authorization_code", "refresh_token"],6 "response_types": ["code"],7 "token_endpoint_auth_method": "none"8}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. 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 |
1{2 "token_kind": "session"3}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:
1curl -X POST https://api.inference.sh/device/auth \2 -H "Content-Type: application/json" \3 -d '{"token_kind":"session"}'1{2 "user_code": "ABCD-2345",3 "device_code": "01HXYZ…",4 "approve_url": "https://app.inference.sh/auth/device?code=ABCD-2345&utm_source=cli&utm_medium=app",5 "poll_url": "https://app.inference.sh/device/auth/poll/01HXYZ…",6 "expires_in": 300,7 "interval": 38}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_downwith the newintervalhere, errorslow_downonPOST /oauth/token), and the code's interval grows by 5 seconds for that poll and every later one. slow_downonly replacespending. 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:
1curl "https://api.inference.sh/device/auth/poll/DEVICE_CODE_HERE"1{2 "status": "approved",3 "session_token": "0zdzfx…",4 "team_id": "team_abc123"5}Using a session token
After approval with token_kind: "session", store the session_token securely and send it on API requests:
1curl https://api.inference.sh/apps/my \2 -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.
Revocation: CLI sessions appear in settings → personal → sessions (auth_method: device-auth) with the polling device's IP and user agent. The belt and infsh CLIs send an RFC 7231 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 below.
Sliding lifetime
CLI session tokens use the same sliding idle window as browser sign-in cookies:
- On each authenticated API request (every
beltcommand that hits the API counts), if less than half of the current window remains, the server movesexpires_atforward to now + 7 days. - The token string does not rotate. The CLI keeps sending the value stored in
~/.inferencesh/config.json(or underBELT_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_atpasses.
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 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 |
1curl https://api.inference.sh/auth/sessions \2 -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).
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. 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) |
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, orexpires_infrom approval).- Legacy (no
token_kind) — CLI API key (30-day expiry by default, orexpires_infrom 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. The approving session cannot grant more than it holds — see 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. |
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. |
1{2 "code": "ABCD-2345",3 "scopes": ["apps:execute", "billing:read"],4 "expires_in": 72005}Response
1{2 "success": true,3 "message": "Device authorized successfully"4}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 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, 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: a login can hold them, an API key cannot. The page reads them from account_scopes and account_groups on GET /scopes. 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
1belt login # device flow → session token (supports belt auth switch)2belt login --key-stdin < key.txt # API key login — team-pinned, for CI / automation3belt auth token # mint a 15m team-pinned API key to stdout (no newline) for scripts; --long-lived prints the stored credential4belt 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. 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 · Coding agents — SessionStart hook
Related
- Authentication — API keys and SDK setup
- REST overview — Base URL and response format