BloopAI/vibe-kanban · error · Error
OAuth redeem failed (${res.status})
Error message
OAuth redeem failed (${res.status}) What it means
redeemOAuth exchanges an OAuth handoff (handoff_id, PKCE app_code and app_verifier) for access/refresh tokens by POSTing to ${API_BASE}/v1/oauth/web/redeem. On any non-OK HTTP status it throws 'OAuth redeem failed (<status>)'. This happens after the user returns from the identity provider, when the client tries to convert the provider authorization code into a session.
Source
Thrown at packages/remote-web/src/shared/lib/api.ts:95
return res.json();
}
export async function redeemOAuth(
handoffId: string,
appCode: string,
appVerifier: string,
): Promise<HandoffRedeemResponse> {
const res = await fetch(`${API_BASE}/v1/oauth/web/redeem`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
handoff_id: handoffId,
app_code: appCode,
app_verifier: appVerifier,
}),
});
if (!res.ok) {
throw new Error(`OAuth redeem failed (${res.status})`);
}
return res.json();
}
export async function localLogin(
email: string,
password: string,
): Promise<LocalLoginResponse> {
const res = await fetch(`${API_BASE}/v1/auth/local/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error(`Local login failed (${res.status})`);
}
return res.json();
}View on GitHub (pinned to 4deb7eca8f)
Solutions
- Check the status: 400/409 with already-used or expired handoff/code means restart the OAuth flow from initOAuth with a fresh PKCE pair.
- Redirect the user back to the login/authorize URL instead of retrying redeem with stale parameters.
- Verify appVerifier corresponds to the exact appChallenge sent to /v1/oauth/web/init (do not regenerate the pair mid-flow).
- Guard against double redemption (e.g. React StrictMode double effects) with a ref/flag so redeem runs once per handoff.
- Clear stale handoff state from the URL/storage before restarting the flow.
- If 5xx, retry once after a short delay; otherwise inspect server logs for /v1/oauth/web/redeem.
Example fix
// before: StrictMode/double-render redeems the same handoff twice -> 409
useEffect(() => { redeemOAuth(id, code, verifier).then(setTokens); }, [id, code, verifier]);
// after: redeem exactly once, restart flow on failure
const done = useRef(false);
useEffect(() => {
if (done.current) return;
done.current = true;
redeemOAuth(id, code, verifier).then(setTokens).catch(() => restartOAuthFlow());
}, [id, code, verifier]); Defensive patterns
Strategy: try-catch
Validate before calling
// validate OAuth callback params before redeeming
const params = new URLSearchParams(window.location.search);
const handoffId = params.get('handoff_id');
const code = params.get('code');
if (!handoffId || !code || !appVerifier) {
throw new Error('Missing handoff_id/code/verifier; restart OAuth flow');
} Type guard
function isHandoffRedeemResponse(x: unknown): x is { access_token: string; refresh_token: string } {
return typeof x === 'object' && x !== null
&& typeof (x as any).access_token === 'string'
&& typeof (x as any).refresh_token === 'string';
} Try / catch
try {
const tokens = await redeemOAuth(handoffId, appCode, appVerifier);
saveTokens(tokens);
} catch (e) {
const status = (e as Error).message.match(/\((\d+)\)/)?.[1];
if (status && ['400', '401', '404', '409'].includes(status)) {
// handoff/code expired or already used — restart the flow with fresh PKCE
await startFreshOAuthFlow(returnTo);
} else {
showError('Sign-in could not be completed; please try again.');
}
} Prevention
- Never reuse a handoff_id or authorization code — always start a fresh initOAuth call.
- Keep the PKCE challenge/verifier pair for the whole flow; do not regenerate between init and redeem.
- Guard redeem against double invocation (React StrictMode, double-click) with a ref or disabling the button.
- Clear one-time OAuth params from the URL after a successful redeem.
- Check tokens expire quickly; complete consent promptly to avoid handoff expiry.
When it happens
Trigger: Calling redeemOAuth(handoffId, appCode, appVerifier) returns non-2xx: the handoff_id expired or was already redeemed (400/404/409), the PKCE code_verifier does not match the challenge from initOAuth (400), the provider code is invalid/expired or was already consumed (400/401), user denied consent so no valid code exists, or the server errors out (500/502/503).
Common situations: User sits on the provider consent screen too long and the handoff expires, then completes login; browser back button re-runs redeem with a one-time code that was already used; multiple tabs finishing OAuth concurrently and consuming the same handoff; server clock/secret rotation invalidating tokens; missing state/PKCE pair after a redirect mishap.
Related errors
- OAuth init failed (${res.status})
- Auth methods lookup failed (${res.status})
- Local login failed (${res.status})
- github token exchange failed: {detail}
- google token exchange failed: {detail}
AI-assisted analysis of BloopAI/vibe-kanban@4deb7eca8f (2026-08-29).
Data as JSON: /api/errors/e95b596af9806a73.
Report an issue: GitHub.