ruvnet/ruflo · error · Error
Invalid or expired state parameter
Error message
Invalid or expired state parameter
What it means
The OAuth manager stores each authorization request under its random state parameter in an in-memory pendingRequests map; exchangeCode deletes the entry on first use and throws when the state is unknown. The error therefore means: wrong state, already-consumed state, or state created by a different process/instance.
Source
Thrown at v3/@claude-flow/mcp/src/oauth.ts:168
codeVerifier,
timestamp: Date.now(),
});
const url = `${this.config.authorizationEndpoint}?${params.toString()}`;
this.logger.debug('Created authorization request', { state, usePKCE: !!codeVerifier });
this.emit('authorization:created', { state });
return { url, state, codeVerifier };
}
/**
* Exchange authorization code for tokens
*/
async exchangeCode(code: string, state: string): Promise<OAuthTokens> {
const pending = this.pendingRequests.get(state);
if (!pending) {
throw new Error('Invalid or expired state parameter');
}
this.pendingRequests.delete(state);
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
});
if (this.config.clientSecret) {
params.set('client_secret', this.config.clientSecret);
}
if (pending.codeVerifier) {
params.set('code_verifier', pending.codeVerifier);
}View on GitHub (pinned to fa13ee4ad6)
Solutions
- Guarantee both legs use the same OAuth manager instance (singleton, sticky sessions, or externalized pending-state)
- Handle the callback exactly once per state; ignore duplicate deliveries
- If the process may restart mid-flow, persist pendingRequests externally (redis/db) or accept restarts as flow restarts
Example fix
// before (new manager per invocation — pendingRequests is empty on the callback instance)
app.get('/callback', async (req) => {
const oauth = new OAuthManager(config);
return oauth.exchangeCode(req.query.code, req.query.state); // throws: unknown state
});
// after (one shared instance for both legs)
const oauth = new OAuthManager(config); // module-level singleton / sticky-routed
app.get('/authorize', () => oauth.createAuthorizationRequest(scopes));
app.get('/callback', (req) => oauth.exchangeCode(req.query.code, req.query.state)); Defensive patterns
Strategy: validation
Validate before calling
// Single shared instance + one-shot callback guard
const oauth = new OAuthManager(config); // module-level singleton, both legs use it
const handledStates = new Set<string>();
app.get('/callback', async (req, res) => {
const { code, state } = req.query as Record<string, string>;
if (handledStates.has(state)) return res.redirect('/already-connected');
handledStates.add(state);
const tokens = await oauth.exchangeCode(code, state);
}); Try / catch
try {
await oauth.exchangeCode(code, state);
} catch (e) {
if (e instanceof Error && e.message === 'Invalid or expired state parameter') {
// restart the flow (new createAuthorizationRequest) — do NOT retry the same state
}
throw e;
} Prevention
- Use one OAuthManager instance for both legs (singleton or sticky sessions)
- Persist pendingRequests externally if deploys or restarts can interrupt flows
- De-duplicate callback deliveries (state is one-shot) at the route layer
When it happens
Trigger: exchangeCode(code, state) where state was never created by createAuthorizationRequest on this instance, was already consumed (map entry deleted), or the app restarted / the callback landed on another instance (pendingRequests is memory-only).
Common situations: Serverless or multi-instance deployments without sticky routing so leg 1 and leg 2 hit different instances; double-handling of the callback URL; an app restart between redirect and callback; testing with a stale callback URL.
Related errors
- state mismatch — the OAuth callback did not match the reques
- hexToBytes: odd-length hex string
- authorization was denied or failed: ${detail}
- login cancelled: no code was entered
- --token-stdin: no input received on stdin
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/3a973c4b86589c80.
Report an issue: GitHub.