NousResearch/hermes-agent · error · Error

Loopback callback state mismatch (possible CSRF)

Error message

Loopback callback state mismatch (possible CSRF)

What it means

CSRF defense per RFC 6749 §10.12: the desktop generates 'state' when starting the native OAuth flow and requires the loopback callback to return the identical value. A missing state on either side, or any mismatch, throws rather than redeeming the authorization code — because a mismatched callback may be a forged redirect injecting an attacker's code (login CSRF / code injection). This error means the defense worked; the callback should not be trusted.

Source

Thrown at apps/desktop/electron/native-oauth.ts:167

  const parsed = new URL(requestUrl, 'http://127.0.0.1')
  const error = parsed.searchParams.get('error')

  if (error) {
    const desc = parsed.searchParams.get('error_description') || ''
    throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)
  }

  const code = parsed.searchParams.get('code') || ''
  const state = parsed.searchParams.get('state') || ''

  if (!code) {
    throw new Error('Loopback callback missing authorization code')
  }

  if (!expectedState || state !== expectedState) {
    // Never redeem a code that arrived with a mismatched state — it may be a
    // forged callback trying to inject an attacker's code.
    throw new Error('Loopback callback state mismatch (possible CSRF)')
  }

  return { code }
}

/**
 * Normalize a `/auth/native/token` (or refresh) JSON response into a
 * NativeTokenSet, validating the shape. Throws on a missing/short access
 * token so a malformed response fails loudly rather than storing junk.
 */
export function parseTokenResponse(body: any): NativeTokenSet {
  const accessToken = String(body?.access_token || '')

  if (!accessToken) {
    throw new Error('Gateway token response missing access_token')
  }

  const expiresAt = Number(body?.expires_at)

View on GitHub (pinned to c896c09c42)

Solutions

  1. Cancel and restart the login flow from scratch — a mismatched state invalidates that callback by design
  2. Ensure only one native OAuth flow runs at a time (disable the sign-in button while a flow is pending)
  3. Avoid reloading the app/window mid-login, which discards expectedState
  4. If it persists, check that the gateway echoes the state parameter verbatim in its redirect

Example fix

// before
const startBtn.onclick = () => void runNativeOAuth() // double-click spawns two flows

// after
let flowRunning = false
const startBtn.onclick = () => { if (flowRunning) return; flowRunning = true; void runNativeOAuth().finally(() => { flowRunning = false }) }
Defensive patterns

Strategy: try-catch

Validate before calling

// Ensure exactly one flow is live before comparing state
if (activeFlow) throw new Error('A login flow is already in progress')
const expectedState = crypto.randomUUID()
activeFlow = { expectedState }
const cb = parseLoopbackCallback(requestUrl, expectedState)

Type guard

function isStateMismatch(e: unknown): boolean { return e instanceof Error && e.message.includes('state mismatch') }

Try / catch

try { const { code } = parseLoopbackCallback(requestUrl, expectedState) } catch (e) { if (isStateMismatch(e)) { cancelFlow(); promptRelogin('Login session expired or forged — restarting sign-in') } else throw e }

Prevention

When it happens

Trigger: Callback's state param differs from the one generated at flow start: two concurrent login attempts where attempt B's callback is checked against attempt A's expectedState; a manually crafted/bookmarked callback URL; expectedState lost because the flow object was recreated (window/app reload mid-flow); a forged redirect from a hostile page.

Common situations: Double-clicking 'sign in' spawning two loopback listeners; renderer reload during login; an attacker or buggy integration redirecting to 127.0.0.1 with their own code+state.

Related errors


AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14). Data as JSON: /api/errors/506ebf0e492aea1e. Report an issue: GitHub.