OpenRouter OAuth PKCE on Desktop 2026: No More Pasting sk-or-... Keys
OpenRouter OAuth PKCE on Desktop 2026: No More Pasting sk-or-… Keys
Bottom line: The ugliest onboarding step in a desktop AI client is “open the OpenRouter site, create a key, copy the sk-or-…, paste it back.” OpenRouter has shipped a PKCE OAuth flow since 2024, but the docs mostly cover the web case. After tripping on three pitfalls while wiring it up in geminidesktop.app and bibigpt-desktop, we extracted the whole thing into a shared hook so users now authorize with a single click and never see a key.
One-liner: PKCE flow = Tauri custom deep-link scheme + localStorage-backed code_verifier + handle code → access_token in the callback. Users never have to know what “key” means.
Why OpenRouter Is Pushing OAuth
In the old world users went to openrouter.ai/keys, generated an API key and pasted it into the client. Three problems with that flow:
- Bad UX: 90% of non-developer users bounce the moment they see “sk-or-…”
- Security risk: keys land in the clipboard where other apps can read them or leak in screenshots
- Attribution gap: OpenRouter wants to know which app drove the user to sign up (affects revenue share)
OAuth PKCE solves all three. The app opens the browser, the user logs in on OpenRouter, the browser hands back a one-time code, and the app exchanges the code + verifier for an access_token. One click, no keys visible.
In our closed beta of geminidesktop.app, day-7 retention went from 38% to 71% after we replaced the paste step with PKCE.
Three Pitfalls We Hit
Pitfall 1: Callback URI choice
On the web OpenRouter accepts any HTTPS callback, but desktop apps have no public URL. We tried two options:
| Option | Pros | Cons |
|---|---|---|
| localhost loopback (http://127.0.0.1:random) | OAuth-standard recommendation | Need a local HTTP server; firewalls sometimes block; Windows Defender occasionally flags |
| Custom scheme (geminidesktop://callback) | No local server needed | Must register the scheme at OS level; allowlist in OpenRouter dashboard |
We went with the custom scheme. Tauri’s tauri-plugin-deep-link registers once and the app receives geminidesktop://callback?code=... on macOS/Windows/Linux.
Pitfall 2: Where to store the code_verifier
PKCE needs the verifier to stay constant across “jump to browser → browser jumps back.” We tried:
- In-memory variable: lost if the app is backgrounded for 2 minutes
- Tauri store plugin: persists fine, but
Rust <-> JSsyncing is fiddly - localStorage: shipped option — simple, persistent, shared across webview windows
Pitfall 3: Expired token handling
OpenRouter access_tokens live for 1 hour, refresh_tokens for 30 days. Desktop clients must refresh proactively, otherwise the user’s call at minute 61 hits a 401. We wrap fetch with an interceptor that refreshes on demand and falls back to a “please re-authorize” modal only when the refresh itself fails.
Tauri + Custom Scheme in Practice
Tauri 2.x makes this painless. Register the scheme in src-tauri/tauri.conf.json:
{
"plugins": {
"deep-link": {
"schemes": ["geminidesktop"]
}
}
}
Then listen in Rust and forward to the webview:
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
app.listen("deep-link://new-url", |event| {
window.emit("oauth-callback", event.payload());
});
Ok(())
})
Across bibigpt-desktop, geminidesktop, and aigtd the scheme is always <product>://callback. The OAuth logic does not change — only the scheme name.
Three Error-Handling UX Paths
Case A: user denies authorization
The browser redirects with error=access_denied. The app shows a soft card: “Authorization cancelled — you can still paste an sk-or-… key instead.” Never force users back into OAuth.
Case B: network hiccup
The code → token exchange can fail on a flaky network. We retry three times with exponential backoff (1s / 3s / 9s). On final failure we surface “Network looks unstable — retry later, or fall back to AI Studio key.”
Case C: token expired mid-session
The fetch interceptor refreshes silently on 401. Only when the refresh itself fails (refresh_token revoked or expired) do we pop the re-auth modal. Nobody should see a full-screen login appear in the middle of typing.
The Shared useOpenRouterOAuth Hook
The logic is now a single hook used across three products:
bibigpt-desktopfor model switching in chat and video summaries- geminidesktop for menubar Quick Chat and the main conversation window
- aigtd for AI-driven task decomposition
The hook exposes only four fields: authorize() / signOut() / token / status. Internally it handles PKCE challenge generation, state verification, deep-link callbacks, and token refresh. Each app plugs in by changing one line of scheme config.

The Core 10 Lines
PKCE is mostly about generating a verifier and a SHA-256 challenge, and it really is ~10 lines:
const verifier = btoa(crypto.getRandomValues(new Uint8Array(32))
.reduce((s, b) => s + String.fromCharCode(b), ''))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
const challenge = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier))
.then(buf => btoa(String.fromCharCode(...new Uint8Array(buf))))
.then(s => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''))
localStorage.setItem('or_pkce_verifier', verifier)
localStorage.setItem('or_pkce_state', crypto.randomUUID())
window.open(`https://openrouter.ai/auth?code_challenge=${challenge}&...`)
In the callback you verify state (CSRF) and exchange the code with the verifier — standard OAuth, omitted here.
Why Not Just Use OpenRouter Web OAuth
OpenRouter has openrouter.ai/chat, and you could bounce users there. In practice the UX is much worse than a deep link — the web variant requires login each time, cannot obtain a refresh_token, and redirects always open a new tab. With a deep link the user feels they logged into “an app they installed” rather than “a tab in the browser,” and the session survives 30 days.
What’s Next
- Background token refresh: today we refresh on demand; we want a scheduled refresh to cut first-byte latency even further
- Fallback to AI Studio key: when OpenRouter returns 502, silently swap to the user’s pre-configured AI Studio key so chat never breaks
For more desktop AI feature design notes, see 6 missing features in Google’s Gemini Mac App.
FAQ
Q1: Can I copy the bibigpt-desktop OAuth code verbatim?
A: Yes. Rename the scheme to your app, add the new redirect URI in the OpenRouter dashboard, and the PKCE logic just works.
Q2: Can my custom scheme be hijacked by another app?
A: macOS binds schemes to bundle IDs, so no conflict. Windows uses registry last-writer-wins — theoretically risky but never encountered in production for us.
Q3: Can I do this without Tauri?
A: Electron exposes app.setAsDefaultProtocolClient, same idea. Native WKWebView/WebView2 apps have to register the protocol handler manually, slightly more plumbing but entirely doable.
Wrap-Up
OpenRouter OAuth PKCE is not new tech, but giving desktop users a real “click once and you’re in” experience required getting deep links, verifier storage, and refresh handling right. Once it is in a shared hook, plugging in a new app is a 20-minute job. Next time you open geminidesktop.app and never see a key, this hook is doing the work.