tinyhumansai/openhuman · error
OpenRouter OAuth callback state did not match the request.
Error message
OpenRouter OAuth callback state did not match the request.
What it means
Standard OAuth 2.0 CSRF protection: the flow starts with a random state, the provider echoes it on redirect, and extractOAuthCode() compares parsed.searchParams.get('state') against expectedState. A mismatch means the callback belongs to a different authorization attempt than the one this client is currently finishing.
Solutions
- Serialize the flow: one OAuth attempt at a time, cancel/close previous tabs and listeners before starting a new one
- Keep expectedState in memory tied to the listener handle so a restart creates a fresh pair instead of matching stale callbacks
- On mismatch, abort and restart the whole flow (new verifier + state) rather than retrying with the old pair
Defensive patterns
Strategy: try-catch
Validate before calling
// Serialize flows so only one state is ever outstanding:
let inflight: AbortController | null = null;
function beginFlow() {
inflight?.abort();
inflight = new AbortController();
return inflight.signal;
} Try / catch
try { const code = extractOAuthCode(callbackUrl, state); }
catch (err) {
if (err instanceof Error && err.message.includes('state did not match')) {
// stale/foreign callback — ignore it, keep waiting for the current state,
// or abort and restart the flow with fresh verifier+state
} else throw err;
} Prevention
- One authorization attempt at a time; abort the previous listener before starting a new one
- Bind expectedState to the listener handle's lifetime, not to a store that survives reloads
- Treat state mismatch as possible CSRF: never proceed with a mismatched callback
When it happens
Trigger: Two OAuth attempts sharing loopback port 3000 (a stale browser tab completing an old flow while a new one waits); app restart between authorize and callback so expectedState was regenerated/lost; a forged or replayed callback hitting the listener.
Common situations: User re-clicks 'Connect OpenRouter' in a second tab; listener from a previous session still bound; expected state kept in a store that was cleared on reload.
Related errors
- OpenRouter key exchange failed
- OpenRouter key exchange succeeded but no API key was…
- OpenRouter OAuth did not return an authorization code.
- OpenRouter OAuth returned an invalid callback URL.
- Command blocked by security policy
AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17).
Data as JSON: /api/errors/bb9e85dc80412922.
Report an issue: GitHub.
Appendix: source
Thrown at app/src/utils/openrouterOAuth.ts:55
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function createCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64UrlEncode(new Uint8Array(digest));
}
function extractOAuthCode(callbackUrl: string, expectedState: string): string {
let parsed: URL;
try {
parsed = new URL(callbackUrl);
} catch {
throw new Error('OpenRouter OAuth returned an invalid callback URL.');
}
const actualState = parsed.searchParams.get('state');
if (actualState !== expectedState) {
throw new Error('OpenRouter OAuth callback state did not match the request.');
}
const code = parsed.searchParams.get('code');
if (!code) {
throw new Error('OpenRouter OAuth did not return an authorization code.');
}
return code;
}
async function exchangeCodeForKey(
code: string,
verifier: string,
fetchImpl: typeof fetch
): Promise<string> {
const response = await fetchImpl(OPENROUTER_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),View on GitHub (pinned to 7491200858)