different-ai/openwork · warning · PluginArchRouteFailure

invalid_github_install_state

invalid_github_install_state

Error message

GitHub install state is invalid or expired.

What it means

consumeGithubInstallState verifies the signed state token from a GitHub App installation callback using verifyGithubInstallStateToken with env.betterAuthSecret. If verification fails (bad signature, expired, or malformed token) it throws a 400 invalid_github_install_state. This protects the OAuth-style install flow against forged or stale state.

Source

Thrown at ee/apps/den-api/src/routes/org/plugin-system/store.ts:4342

  return { enqueuedCount }
}

function githubConnectorAppConfig() {
  try {
    return getGithubConnectorAppConfig(env.githubConnectorApp)
  } catch (error) {
    if (error instanceof GithubConnectorConfigError) {
      throw new PluginArchRouteFailure(409, "github_connector_app_not_configured", error.message)
    }
    throw error
  }
}

export function consumeGithubInstallState(state: string) {
  const parsed = verifyGithubInstallStateToken({ secret: env.betterAuthSecret, token: state })
  if (!parsed) {
    throw new PluginArchRouteFailure(400, "invalid_github_install_state", "GitHub install state is invalid or expired.")
  }
  return parsed
}

function wrapGithubConnectorError(error: unknown): never {
  if (error instanceof PluginArchRouteFailure) {
    throw error
  }

  if (error instanceof GithubConnectorConfigError) {
    throw new PluginArchRouteFailure(409, "github_connector_app_not_configured", error.message)
  }

  if (error instanceof GithubConnectorRequestError) {
    throw new PluginArchRouteFailure(409, "github_connector_request_failed", error.message, { cause: error })
  }

  throw error

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Restart the install flow: navigate to the install start endpoint again so a fresh state token is issued
  2. Confirm betterAuthSecret has not rotated and is identical across all server instances handling the flow
  3. Complete the GitHub installation promptly without long pauses
  4. Inspect the callback URL to ensure the state query parameter is intact and not URL-decoded/re-encoded by a proxy

Example fix

// before
// secret rotated between authorize start and callback -> old state unverifiable
// after
// keep betterAuthSecret stable across instances, or invalidate in-flight installs on rotation:
// rotate secret, then re-initiate: window.location = installStartUrl (new signed state issued)
Defensive patterns

Strategy: try-catch

Validate before calling

// client-side: detect stale/expired state before it matters
const startedAt = Number(localStorage.getItem("ghInstallStartedAt") ?? 0)
if (Date.now() - startedAt > STATE_TTL_MS) {
  // state token likely expired — restart the install flow
  location.assign(installStartUrl)
}

Type guard

function isInvalidInstallState(e: unknown): boolean {
  return e instanceof PluginArchRouteFailure && e.code === "invalid_github_install_state"
}

Try / catch

try {
  return await handleGithubInstallCallback(searchParams)
} catch (e) {
  if (isInvalidInstallState(e)) {
    return redirectTo(installStartUrl) // fresh signed state, re-run flow
  }
  throw e
}

Prevention

When it happens

Trigger: GitHub redirects back to the install callback with a state param that is expired, signed with a different secret (e.g. betterAuthSecret rotated between start and callback), truncated by the client, or fabricated.

Common situations: User sat on the GitHub install page past token TTL and then completed it; server restarted with a rotated betterAuthSecret mid-install; load balancer routing callback to a different env; proxy mangling the query string.

Related errors


AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01). Data as JSON: /api/errors/09c63bfcaf06b7a3. Report an issue: GitHub.