AnyRouter

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.

ParameterEffect
callback_urlWhere to send the user after approval. Omit for headless mode.
code_challengePKCE challenge. Required when callback_url is omitted.
code_challenge_methodS256 or plain. Defaults to plain when a challenge is sent without a method.
key_labelPrefills the name of the key that will be created.
workspace_idPreselects the workspace for the new key. The user can change it.
required_workspace_idRequires 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.
limitPrefills a spend limit for the key, in USD.
usage_limit_typeHow often the limit resets: daily, weekly or monthly.
expires_atPrefills 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:

StatusMessageCause
400Invalid code_challenge_methodThe code_challenge_method does not match the one used in step 1.
403Invalid code or code_verifierThe code is unknown or already used, or the code_verifier is missing or does not match the challenge.
403Authorization code expiredMore than 10 minutes passed since the user approved. Send the user through step 1 again.
405Method Not AllowedThe 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:

StepOpenRouterAnyRouter
Authorizehttps://openrouter.ai/auth?...https://anyrouter.dev/auth?...
Exchangehttps://openrouter.ai/api/v1/auth/keyshttps://anyrouter.dev/api/v1/auth/keys
Inferencehttps://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 linkLands 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.