AnyRouter

OAuth

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

Building the OpenRouter-style flow where a user approves your app on https://anyrouter.dev/auth and your app receives their API key? That flow is OpenRouter-compatible and needs no existing key. See the OAuth PKCE guide. Its code exchange is a public POST /api/v1/auth/keys with { code, code_verifier, code_challenge_method }, returning { key, user_id }. The rest of this page covers minting a key from a key you already hold, and device authorization.

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 — creating the code authenticates 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.

Creating the code authenticates with an LLM API key (sk-ar-…) sent as the Bearer token. The exchange needs no key: the code_verifier proves it is you. The new key is created in your 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/auth/keys (code + code_verifier)
    AnyRouter-->>App: new sk-ar-v1-… key (shown once)

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

FieldTypeRequiredDescription
callback_urlstringYesAny https URL, or http on localhost, 127.0.0.1 or [::1] on any port.
code_challengestringYesPKCE challenge.
code_challenge_methodS256 | plainNoDefaults to S256. plain is accepted.
expires_atstring | nullNoExpiration of the new key (future ISO 8601 timestamp). The authorization code itself always expires after 10 minutes.
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"
  }
}

POST /api/v1/auth/keys with the code and the PKCE verifier. No Authorization header is needed.

FieldTypeRequiredDescription
codestringYesAuthorization code id from step 1.
code_verifierstringYesPKCE verifier (43-128 characters).
code_challenge_methodS256 | plainNoMust match step 1 when sent.

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"
  }'

callback_url must be an https:// URL, or http:// on localhost, 127.0.0.1 or [::1] (any port). Authorization codes expire after 10 minutes and are single-use, 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

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.

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"}'

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 (or loopback http) callback_url 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.