# 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)](https://oauth.net/2/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.

:::note
Looking for a registered app with a "Sign in with AnyRouter" button and revocable tokens? See [Sign in with AnyRouter](/guides/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

::::steps

:::step{title="Send your user to AnyRouter"}
Send the user to the `/auth` page with a `callback_url` pointing back to your app:

```txt
# 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`.
:::

:::step{title="Exchange the code for a key"}
Make a `POST` request to `https://anyrouter.dev/api/v1/auth/keys`. No `Authorization` header is needed.

```js
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:

```json
{
  "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.
:::

:::step{title="Use the key"}
Store the key for that user and send it as a Bearer token:

```js
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`:

```txt
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.

```html
<!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>
```

:::warning
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](https://anyrouter.dev/dashboard/keys) 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. Deep links to a key's activity or settings page (such as `/logs?api_key_hash=`) are not supported yet.


## Related

- [Sign in with AnyRouter](/docs/guides/sign-in-with-anyrouter.md)
- [OAuth](/docs/api-reference/oauth.md)
- [OpenRouter](/docs/guides/migrate-from-openrouter.md)
- [Chat Completions API](/docs/api-reference/chat-completions.md)
