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.
/api/v1/auth/keys/codeBoth 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.
| Field | Type | Required | Description |
|---|---|---|---|
callback_url | string | Yes | HTTPS callback URL. Only ports 443 and 3000 are accepted. |
code_challenge | string | Yes | PKCE challenge. |
code_challenge_method | S256 | plain | No | Defaults to S256. |
expires_at | string | null | No | Custom expiration time. |
key_label | string | No | Label for the eventual generated key. |
limit | number | No | Credit cap for the key. |
usage_limit_type | daily | weekly | monthly | No | Reset 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.
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Authorization code id from step 1. |
code_verifier | string | No | PKCE verifier. |
code_challenge_method | S256 | plain | null | No | Method 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.
| Field | Type | Description |
|---|---|---|
client_name | string | Name shown to the user on the approval screen. Ignored if client_id is set and resolves — the registered name is shown instead. |
client_id | string | Optional. 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. |
scope | string | Space-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_label | string | Suggested 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:
error | Meaning |
|---|---|
authorization_pending | Not approved yet. Keep polling. |
slow_down | You polled too fast. Wait at least interval seconds. |
access_denied | The user declined. Stop polling. |
expired_token | The 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"
}
}
}
| Status | Meaning | Fix |
|---|---|---|
| 400 | Missing/invalid field (e.g. callback_url) | Send a valid HTTPS callback_url on port 443 or 3000 and a PKCE challenge. |
| 400 | invalid_client — device-flow client_id isn't a registered app | Omit client_id (use client_name instead), or register first via POST /api/v1/mcp/oauth/register. |
| 400 | invalid_scope — none of the requested device-flow scopes are recognized | Request valid scopes, e.g. inference read:credits, or omit scope for the default set. |
| 401 | Invalid or expired bearer key | Send a valid sk-ar-… key. |