About openrouter-oauth
openrouter-oauth is a skill for adding OpenRouter sign-in to web apps. It describes a PKCE-based flow that obtains an API key without client registration or a backend, along with guidance for a sign-in button.
Key features
- 01Implement the OAuth PKCE flow
- 02Exchange an authorization code for an API key
- 03Manage verifier and key storage
- 04Synchronize sign-in state across tabs
- 05Build sign-in button variants
Use cases
- 01Add OpenRouter sign-in to a web app
- 02Obtain an API key without a sign-in UI
- 03Connect authentication to OpenRouter API calls
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ openrouterteam/ skills --skill openrouter-oauthAfter installing, type/openrouter-oauthin Claude Code to confirm it loaded.
Bundled filesREADME.md
Details
From the project READMESign In with OpenRouter
Add OAuth login to any web app. Users authorize on OpenRouter and your app receives an API key — no client registration, no backend, no secrets. Works with any framework.
Live demo: openrouterteam.github.io/sign-in-with-openrouter
Decision Tree
| User wants to… | Do this |
|---|---|
| Add sign-in / login to a web app | Follow the full PKCE flow + button guidance below |
| Get an API key programmatically (no UI) | Just implement the PKCE flow — skip the button section |
| Use the OpenRouter SDK after auth | Do PKCE here for the key, then see openrouter-typescript-sdk skill for callModel/streaming |
OAuth PKCE Flow
No client ID or secret — the PKCE challenge is the only proof of identity.
Step 1: Generate verifier and challenge
code_verifier = base64url(32 random bytes)
code_challenge = base64url(SHA-256(code_verifier))
- Use
crypto.getRandomValues(new Uint8Array(32))for the random bytes - base64url encoding: standard base64, then replace
+→-,/→_, strip trailing= - Store
code_verifierinsessionStorage(notlocalStorage) — so the verifier doesn't persist after the tab closes or leak to other tabs (security: the verifier is a one-time secret)
Step 2: Redirect to OpenRouter
https://openrouter.ai/auth?callback_url={url}&code_challenge={challenge}&code_challenge_method=S256
| Param | Value |
|---|---|
callback_url | Your app's URL (where the user returns after auth) |
code_challenge | The S256 challenge from Step 1 |
code_challenge_method | Always S256 |
Step 3: Handle the redirect back
User returns to your callback_url with ?code= appended. Extract the code query parameter.
Important: Before processing ?code=, check that a code_verifier exists in sessionStorage. Other routes or third-party code might use ?code= query params for unrelated purposes — a hasOAuthCallbackPending() guard ensures you only consume codes that belong to your OAuth flow.
Step 4: Exchange code for API key
POST https://openrouter.ai/api/v1/auth/keys
Content-Type: application/json
{
"code": "<code from query param>",
"code_verifier": "<verifier from sessionStorage>",
"code_challenge_method": "S256"
}
→ { "key": "sk-or-..." }
Remove the verifier from sessionStorage before or after the exchange.
Step 5: Store the key and clean up
- Store
keyinlocalStorage - Clean the URL:
history.replaceState({}, "", location.pathname)to remove?code= - Cross-tab sync: Listen for
storageevents on the API key'slocalStorageentry so other tabs update when the user signs in or out
Auth Module Reference
Drop-in module implementing the full PKCE flow. Reduces risk of getting base64url encoding, sessionStorage handling, or the key exchange wrong.
// lib/openrouter-auth.ts
const STORAGE_KEY = "openrouter_api_key";
const VERIFIER_KEY = "openrouter_code_verifier";
type AuthListener = () => void;
const listeners = new Set<AuthListener>();
export const onAuthChange = (fn: AuthListener) => { listeners.add(fn); return () => listeners.delete(fn); };
const notify = () => listeners.forEach((fn) => fn());
// Cross-tab sync: other tabs update when user signs in/out
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => { if (e.key === STORAGE_KEY) notify(); });
}
export const getApiKey = (): string | null =>
typeof window !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
export const setApiKey = (key: string) => { localStorage.setItem(STORAGE_KEY, key); notify(); };
export const clearApiKey = () => { localStorage.removeItem(STORAGE_KEY); notify(); };
// Guard: only process ?code= if we initiated an OAuth flow in this tab
export const hasOAuthCallbackPending = (): boolean =>
typeof window !== "undefined" && sessionStorage.getItem(VERIFIER_KEY) !== null;
function generateCodeVerifier(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function computeS256Challenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function initiateOAuth(callbackUrl?: string): Promise<void> {
const verifier = generateCodeVerifier();
sessionStorage.setItem(VERIFIER_KEY, verifier);
const challenge = await computeS256Challenge(verifier);
const url = callbackUrl ?? window.location.origin + window.location.pathname;
window.location.href = `https://openrouter.ai/auth?${new URLSearchParams({
callback_url: url, code_challenge: challenge, code_challenge_method: "S256",
})}`;
}
export async function handleOAuthCallback(code: string): Promise<void> {
const verifier = sessionStorage.getItem(VERIFIER_KEY);
if (!verifier) throw new Error("Missing code verifier");
sessionStorage.removeItem(VERIFIER_KEY);
const res = await fetch("https://openrouter.ai/api/v1/auth/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
});
if (!res.ok) throw new Error(`Key exchange failed (${res.status})`);
const { key } = await res.json();
setApiKey(key);
}
Sign-in Button
Build a button component that calls initiateOAuth() on click. Include the OpenRouter logo and provide multiple visual variants.
OpenRouter Logo SVG
<svg viewBox="0 0 401.4 293.7" fill="currentColor">
<path d="M303.9475,17.19926c42.79734,0,77.48933,34.69327,77.48933,77.48933s-34.69199,77.48933-77.48933,77.48933l76.86166,76.86244c9.76367,9.76313,2.84903,26.45667-10.95697,26.45667h-220.88335c-71.32686,0-129.14889-57.82202-129.14889-129.14889S77.64197,17.19926,148.96884,17.19926h154.97866ZM148.96884,68.85881c-42.79607,0-77.48933,34.69327-77.48933,77.48933s34.69327,77.48933,77.48933,77.48933,77.48933-34.69327,77.48933-77.48933-34.69327-77.48933-77.48933-77.48933Z"/>
</svg>
fill="currentColor" inherits the button's text color. To use the official brand colors instead, apply the light-mode purple and dark-mode lime:
<svg viewBox="0 0 401.4 293.7" class="fill-[#7624F4] dark:fill-[#C8FF00]">...</svg>
| Mode | Fill |
|---|---|
| Light backgrounds | #7624F4 |
| Dark backgrounds | #C8FF00 |
Variants (Tailwind)
Recommended classes for visual consistency with the reference implementation:
| Variant | Classes |
|---|---|
default | rounded-lg border border-neutral-300 bg-white text-neutral-900 shadow-sm hover:bg-neutral-50 |
minimal | text-neutral-700 underline-offset-4 hover:underline |
branded | rounded-lg bg-neutral-900 text-white shadow hover:bg-neutral-800 |
icon | Same as default + aspect-square (logo only, no text) |
cta | rounded-xl bg-neutral-900 text-white shadow-lg hover:bg-neutral-800 hover:scale-[1.02] active:scale-[0.98] |
Sizes
| Size | Classes |
|---|---|
sm | h-8 px-3 text-xs |
default | h-10 px-5 text-sm |
lg | h-12 px-8 text-base |
xl | h-14 px-10 text-lg |
All variants use: inline-flex items-center justify-center gap-2 font-medium transition-all cursor-pointer disabled:opacity-50
Show a loading indicator while the key exchange is in progress. Default label: "Sign in with OpenRouter".
Dark mode
For dark mode support, add dark variants: swap light backgrounds to dark (dark:bg-neutral-900 dark:text-white) and vice versa for branded/cta (dark:bg-white dark:text-neutral-900).
Using the API Key
const response = await fetch("https://openrouter.ai/api/v1/responses", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
input: [{ type: "message", role: "user", content: "Hello!" }],
}),
});
For the type-safe SDK approach (callModel, streaming, tool use), see the openrouter-typescript-sdk skill.
Resources
- OAuth PKCE guide — full parameter reference and key management
- Authentication guide — API key usage and Bearer token setup
- Live demo — interactive button playground
- OpenRouter TypeScript SDK —
callModelpattern for completions and streaming