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.
| Field | Type | Required | Description |
|---|---|---|---|
callback_url | string | Yes | Any https URL, or http on localhost, 127.0.0.1 or [::1] on any port. |
code_challenge | string | Yes | PKCE challenge. |
code_challenge_method | S256 | plain | No | Defaults to S256. plain is accepted. |
expires_at | string | null | No | Expiration of the new key (future ISO 8601 timestamp). The authorization code itself always expires after 10 minutes. |
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"
}
}POST /api/v1/auth/keys with the code and the PKCE verifier. No Authorization header is needed.
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Authorization code id from step 1. |
code_verifier | string | Yes | PKCE verifier (43-128 characters). |
code_challenge_method | S256 | plain | No | Must 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"
}'curl https://anyrouter.dev/api/v1/auth/keys \
-X POST \
-H "Content-Type: application/json" \
-d '{
"code": "authcode_123",
"code_verifier": "pkce_verifier_value",
"code_challenge_method": "S256"
}'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
endPOST /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.
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 (or loopback http) callback_url 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. |