Skip to content

OAuth

Mint a scoped API key from an existing key (PKCE), or authorize a browser-less device. For "let my users sign in with their AnyRouter account," see the Sign in with AnyRouter guide instead.

This page covers two flows that both require an AnyRouter account you already control: minting an additional key via PKCE, and the device authorization grant for browser-less apps. Neither is the right fit for "let anonymous users sign in with their own AnyRouter account and bill their own credits" — that's Sign in with AnyRouter, a separate open-registration OAuth 2.1 flow with no bearer key required to start.

Mint a key via PKCE

A lightweight flow so a trusted local tool (already holding one of your keys, e.g. a CLI or a dev server on localhost:3000) can generate a new scoped key without you copying it out of the dashboard by hand. Two endpoints drive it: create an authorization code, then exchange it for a new key. This is not an anonymous sign-in flow — both steps authenticate with an existing sk-ar-… key, so an app with no key cannot start it. Building "sign in with AnyRouter" for third-party users? Use the Sign in with AnyRouter guide instead.

POST/api/v1/auth/keys/code

Both endpoints authenticate with an LLM API key (sk-ar-…) sent as the Bearer token. The exchanged key is created in the caller's personal workspace.

The flow

sequenceDiagram
    participant App
    participant AnyRouter
    App->>App: Generate code_verifier + code_challenge (S256)
    App->>AnyRouter: POST /api/v1/auth/keys/code (challenge)
    AnyRouter-->>App: authorization code id
    App->>AnyRouter: POST /api/v1/keys (code + code_verifier)
    AnyRouter-->>App: new sk-ar-v1-… key (shown once)

Create an authorization code

POST /api/v1/auth/keys/code with the PKCE challenge and callback details.

FieldTypeRequiredDescription
callback_urlstringYesHTTPS callback URL. Only ports 443 and 3000 are accepted.
code_challengestringYesPKCE challenge.
code_challenge_methodS256 | plainNoDefaults to S256.
expires_atstring | nullNoCustom expiration time.
key_labelstringNoLabel for the eventual generated key.
limitnumberNoCredit cap for the key.
usage_limit_typedaily | weekly | monthlyNoReset interval for limit.

The response returns the authorization code id:

{
  "data": {
    "id": "authcode_123",
    "app_id": 1,
    "created_at": "2026-04-24T00:00:00.000Z"
  }
}

Exchange the code for a key

POST /api/v1/keys with the code and the PKCE verifier, using the same bearer key.

FieldTypeRequiredDescription
codestringYesAuthorization code id from step 1.
code_verifierstringNoPKCE verifier.
code_challenge_methodS256 | plain | nullNoMethod hint.

The response returns the plaintext key once:

{
  "key": "sk-ar-v1-actual-secret-only-shown-once",
  "user_id": "user_123"
}

Examples

curl https://anyrouter.dev/api/v1/auth/keys/code \
  -X POST \
  -H "Authorization: Bearer sk-ar-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://example.com:443/callback",
    "code_challenge": "pkce_challenge_value",
    "code_challenge_method": "S256",
    "key_label": "oauth-app",
    "limit": 25,
    "usage_limit_type": "monthly"
  }'
curl https://anyrouter.dev/api/v1/keys \
  -X POST \
  -H "Authorization: Bearer sk-ar-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "authcode_123",
    "code_verifier": "pkce_verifier_value",
    "code_challenge_method": "S256"
  }'

callback_url must use https://, and only ports 443 and 3000 are accepted. Authorization codes expire quickly by default, so exchange them promptly.

Device authorization

For apps that can't open a browser — a CLI, a terminal tool, a headless box — use the device authorization grant (RFC 8628). Your app shows the user a short code; the user approves it on any device with a browser; your app polls until the credential is ready.

Unlike the flow above, this one needs no existing API key: the user's approval in the browser is the authentication.

client_id is optional, but if you send one it must already exist. Unregistered apps should either omit client_id entirely (and pass client_name instead — see the table below) or register first via POST /api/v1/mcp/oauth/register to get a real client_id. Sending a made-up client_id fails with 400 invalid_client, even though no key was required to get this far:

{ "error": "invalid_client", "error_description": "Unknown client_id" }
sequenceDiagram
    participant App
    participant User
    participant AnyRouter
    App->>AnyRouter: POST /api/v1/oauth/device/code
    AnyRouter-->>App: user_code + verification_uri
    App->>User: Show the code and the URL
    User->>AnyRouter: Open the URL, review permissions, approve
    loop every `interval` seconds
        App->>AnyRouter: POST /api/v1/oauth/token (device_code)
        AnyRouter-->>App: authorization_pending → access_token
    end

Request a device code

POST /api/v1/oauth/device/code. Accepts form-encoded or JSON; every field is optional.

FieldTypeDescription
client_namestringName shown to the user on the approval screen. Ignored if client_id is set and resolves — the registered name is shown instead.
client_idstringOptional. Must be a client_id returned by POST /api/v1/mcp/oauth/register — omit it entirely for an unregistered app; don't invent one. An unrecognized client_id returns 400 invalid_client.
scopestringSpace-separated permissions to request, e.g. inference read:credits write:llm-keys. Unrecognized scope strings are dropped; if none survive, the response is 400 invalid_scope. Omit to request the full device-flow set; the user can narrow it further at approval.
key_labelstringSuggested name for the key that gets created.
{
  "device_code": "8f2a…",
  "user_code": "K4RT-9WPZ",
  "verification_uri": "https://anyrouter.dev/cli/device",
  "verification_uri_complete": "https://anyrouter.dev/cli/device?code=K4RT-9WPZ",
  "expires_in": 600,
  "interval": 5
}

Show the user user_code and verification_uri. If you can open a link for them, use verification_uri_complete — it pre-fills the code.

Poll for the token

POST /api/v1/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code, no more often than interval seconds.

While the user hasn't finished, you get a 400 with one of:

errorMeaning
authorization_pendingNot approved yet. Keep polling.
slow_downYou polled too fast. Wait at least interval seconds.
access_deniedThe user declined. Stop polling.
expired_tokenThe code expired or was already used. Start over.

On approval you get the credential:

{
  "access_token": "sk-ar-v1-actual-secret-only-shown-once",
  "token_type": "Bearer",
  "scope": "inference read:credits",
  "user_id": "user_123"
}
curl https://anyrouter.dev/api/v1/oauth/device/code \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"client_name": "My CLI", "scope": "inference read:credits"}'
curl https://anyrouter.dev/api/v1/oauth/token \
  -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "device_code=8f2a..."

scope is a request, not a guarantee. The approval screen lets the user hand over less than you asked for, so read the scope field on the response to see what you actually received. Users can revoke access at any time from their dashboard.

Errors

Errors use the standard envelope:

{
  "error": {
    "code": "bad_request",
    "message": "callback_url is required",
    "metadata": {
      "type": "invalid_request_error"
    }
  }
}
StatusMeaningFix
400Missing/invalid field (e.g. callback_url)Send a valid HTTPS callback_url on port 443 or 3000 and a PKCE challenge.
400invalid_client — device-flow client_id isn't a registered appOmit client_id (use client_name instead), or register first via POST /api/v1/mcp/oauth/register.
400invalid_scope — none of the requested device-flow scopes are recognizedRequest valid scopes, e.g. inference read:credits, or omit scope for the default set.
401Invalid or expired bearer keySend a valid sk-ar-… key.

Related