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

  1. Guarantee both legs use the same OAuth manager instance (singleton, sticky sessions, or externalized pending-state)
  2. Handle the callback exactly once per state; ignore duplicate deliveries
  3. 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

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


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/3a973c4b86589c80. Report an issue: GitHub.