OAuth PKCE
Let users connect their AnyRouter account in one click and hand your app their own API key, using the same PKCE flow as OpenRouter.
OAuth PKCE
Users can connect to AnyRouter in one click using Proof Key for Code Exchange (PKCE). Your app sends the user to AnyRouter, the user approves, and your app exchanges a short-lived code for an API key that belongs to that user. Usage on that key is billed to the user's own account.
The flow is drop-in compatible with OpenRouter's OAuth PKCE flow. It needs no client registration and no client secret, so it works from a browser-only app, a CLI, or a local tool.
Looking for a registered app with a "Sign in with AnyRouter" button and revocable tokens? See Sign in with AnyRouter. This guide covers the simpler flow where the user approves once and your app receives a regular API key.
How it works
Send the user to the /auth page with a callback_url pointing back to your app:
# S256 code challenge (recommended)
https://anyrouter.dev/auth?callback_url=<YOUR_SITE_URL>&code_challenge=<CODE_CHALLENGE>&code_challenge_method=S256
# Plain code challenge
https://anyrouter.dev/auth?callback_url=<YOUR_SITE_URL>&code_challenge=<CODE_CHALLENGE>&code_challenge_method=plain
# No code challenge
https://anyrouter.dev/auth?callback_url=<YOUR_SITE_URL>The user signs in to AnyRouter if needed and sees a consent card with your app's name. When they approve, they are redirected back to your callback_url with a code query parameter. Any query string already on your callback_url is kept.
For S256, set code_challenge to the base64url encoding of the SHA-256 hash of your code_verifier. The verifier is a random string of 43 to 128 characters (letters, digits, -, ., _, ~). Keep the verifier in your app; never put it in the URL.
The code_challenge is optional when you pass a callback_url, but recommended. With a challenge, the code is useless to anyone who does not hold your verifier. Without one, the code is only protected by being delivered to your callback_url: anyone who gets hold of it before you exchange it can use it.
If the user denies the request, they are redirected to your callback_url with ?error=access_denied.
Make a POST request to https://anyrouter.dev/api/v1/auth/keys. No Authorization header is needed.
const response = await fetch("https://anyrouter.dev/api/v1/auth/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: "<CODE_FROM_QUERY_PARAM>",
code_verifier: "<CODE_VERIFIER>", // if code_challenge was used
code_challenge_method: "<CODE_CHALLENGE_METHOD>", // if code_challenge was used
}),
})
const { key, user_id } = await response.json()The response contains the new key and the user's id:
{
"key": "sk-ar-v1-actual-secret-only-shown-once",
"user_id": "user_123"
}If you sent a challenge in step 1, the code_verifier is required. code_challenge_method must match the method you used in step 1.
Store the key for that user and send it as a Bearer token:
const completion = await fetch("https://anyrouter.dev/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "anthropic/claude-sonnet-4.6",
messages: [{ role: "user", content: "Hello!" }],
}),
})Authorization codes are single-use and expire after 10 minutes.
Localhost apps
Localhost callbacks work on any port. This suits CLI tools and local-first apps that bind a free port for the callback, for example http://localhost:51423/callback. http://127.0.0.1 and http://[::1] work the same way. Every other callback must use https.
Apps with a localhost callback are named after their host and port (for example localhost:3000). Other apps are named after their host. That name appears on the consent card and on the key in the user's dashboard.
Headless apps
If your app runs where a callback cannot be reached (an SSH session, a remote machine, a container), leave out callback_url:
https://anyrouter.dev/auth?code_challenge=<CODE_CHALLENGE>&code_challenge_method=S256&key_label=<YOUR_APP_NAME>
After the user approves, the page shows the code on screen instead of redirecting. The user copies it and pastes it into your app, and you exchange it in step 2 as usual.
A code_challenge is required in this mode. The code is displayed on screen, so PKCE is what keeps it useless to anyone without your verifier.
Optional parameters
Add these to the /auth URL. They prefill the consent form, and the user can change them before approving.
| Parameter | Effect |
|---|---|
callback_url | Where to send the user after approval. Omit for headless mode. |
code_challenge | PKCE challenge. Required when callback_url is omitted. |
code_challenge_method | S256 or plain. Defaults to plain when a challenge is sent without a method. |
key_label | Prefills the name of the key that will be created. |
workspace_id | Preselects the workspace for the new key. The user can change it. |
required_workspace_id | Requires the key to be created in this workspace. The choice is locked, and the user cannot approve if they are not a member. Takes precedence over workspace_id. |
limit | Prefills a spend limit for the key, in USD. |
usage_limit_type | How often the limit resets: daily, weekly or monthly. |
expires_at | Prefills when the key expires, as an ISO 8601 timestamp in the future. |
Workspaces are always checked against the signed-in user's memberships. A workspace they cannot access is rejected.
Complete browser example
A single self-contained page in plain browser JavaScript, with no bundler. Save it as index.html, serve it over https (or from localhost), and replace CALLBACK_URL if you serve it somewhere other than the default.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Connect AnyRouter</title>
</head>
<body>
<button id="connect">Connect AnyRouter</button>
<pre id="output"></pre>
<script>
// The page the user returns to after approving. Must be https, or http on localhost.
const CALLBACK_URL = window.location.origin + window.location.pathname
const output = document.getElementById("output")
function base64url(bytes) {
let binary = ""
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function randomVerifier() {
// 32 random bytes encode to 43 base64url characters.
return base64url(crypto.getRandomValues(new Uint8Array(32)))
}
async function s256Challenge(verifier) {
const data = new TextEncoder().encode(verifier)
return base64url(await crypto.subtle.digest("SHA-256", data))
}
// Step 1: create a verifier, keep it, and send the user to AnyRouter.
document.getElementById("connect").addEventListener("click", async () => {
const verifier = randomVerifier()
sessionStorage.setItem("anyrouter_code_verifier", verifier)
const url = new URL("https://anyrouter.dev/auth")
url.searchParams.set("callback_url", CALLBACK_URL)
url.searchParams.set("code_challenge", await s256Challenge(verifier))
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("key_label", "My browser app")
window.location.href = url.toString()
})
// Step 2: back from AnyRouter with ?code=... (or ?error=access_denied).
async function handleReturn() {
const params = new URLSearchParams(window.location.search)
if (params.get("error")) {
output.textContent = "Not connected: " + params.get("error")
return
}
const code = params.get("code")
if (!code) return
const verifier = sessionStorage.getItem("anyrouter_code_verifier")
sessionStorage.removeItem("anyrouter_code_verifier")
// Remove the single-use code from the address bar.
history.replaceState(null, "", window.location.pathname)
const response = await fetch("https://anyrouter.dev/api/v1/auth/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
code_verifier: verifier,
code_challenge_method: "S256",
}),
})
const result = await response.json()
if (!response.ok) {
output.textContent = "Exchange failed: " + JSON.stringify(result)
return
}
// Step 3: you now hold the user's key. Store it and call the API with it.
output.textContent = "Your key: " + result.key
}
handleReturn()
</script>
</body>
</html>
This example prints the key so you can see it work. In a real app, store the key for that user and never log it or show it to anyone else.
Errors
The exchange returns these statuses:
| Status | Message | Cause |
|---|---|---|
| 400 | Invalid code_challenge_method | The code_challenge_method does not match the one used in step 1. |
| 403 | Invalid code or code_verifier | The code is unknown or already used, or the code_verifier is missing or does not match the challenge. |
| 403 | Authorization code expired | More than 10 minutes passed since the user approved. Send the user through step 1 again. |
| 405 | Method Not Allowed | The exchange accepts POST only. |
Other malformed requests, such as a missing code, return 400.
Managing and revoking keys
The key appears on the user's Keys page with your app's name, so they can see which app created it. They can revoke it there at any time, and it stops working immediately. If the user set a spend limit or expiry on the consent card, it applies to the key. Your app should handle a 401 by sending the user through the flow again.
Migrating from OpenRouter
If you already use OpenRouter's OAuth PKCE flow, swap openrouter.ai for anyrouter.dev:
| Step | OpenRouter | AnyRouter |
|---|---|---|
| Authorize | https://openrouter.ai/auth?... | https://anyrouter.dev/auth?... |
| Exchange | https://openrouter.ai/api/v1/auth/keys | https://anyrouter.dev/api/v1/auth/keys |
| Inference | https://openrouter.ai/api/v1/... | https://anyrouter.dev/api/v1/... |
Parameters, response shape and error statuses are the same. Two differences: the exchange response also includes user_id, and AnyRouter adds the optional limit, usage_limit_type and expires_at parameters.
Linking users to a key
OpenRouter's PKCE guide tells your app to deep-link the user to the key it just created, so your connection page lands on that key instead of a bare dashboard. AnyRouter accepts the same two addresses:
| Deep link | Lands on |
|---|---|
https://anyrouter.dev/keys/<SHA256_OF_KEY> | That key's page, with its usage, spend, routing and settings |
https://anyrouter.dev/logs?api_key_hash=<SHA256_OF_KEY> | Request logs, filtered to that key |
The hash is the lowercase hex SHA-256 of the key string — the same digest AnyRouter stores, so your app can compute it without asking the user for anything:
const hashHex = Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))),
)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
location.href = `https://anyrouter.dev/keys/${hashHex}`
A deep link only resolves inside the account that owns the key. If the visitor is signed out, or the hash belongs to a different account, the page returns 404 instead of showing an unfiltered key or log view. Add ?org=<workspace_id> to /logs?api_key_hash= when you created the key in a specific workspace.