toeverything/AFFiNE · error · InvalidAuthState

invalid_auth_state

invalid_auth_state

Error message

Invalid auth state. You might start the auth progress from another device.

What it means

InvalidAuthState thrown at packages/backend/server/src/plugins/oauth/service.ts:134 when the client_nonce posted to /api/oauth/callback does not match the clientNonce stored in the state at preflight time (non-Apple providers only). The nonce is an anti-CSRF/anti-cross-device binding: the browser that started the flow must be the one that finishes it, and Apple flows are exempted because of their form_post handoff.

Source

Thrown at packages/backend/server/src/plugins/oauth/service.ts:134

    }

    if (!state.provider) {
      throw new MissingOauthQueryParameter({ name: 'provider' });
    }

    const provider = this.providerFactory.get(state.provider);

    if (!provider) {
      throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
    }

    if (
      state.provider !== OAuthProviderName.Apple &&
      (!input.clientNonce ||
        !state.clientNonce ||
        state.clientNonce !== input.clientNonce)
    ) {
      throw new InvalidAuthState();
    }

    return {
      type: 'identity',
      identity: await this.verifyCallbackIdentity(
        input.code,
        state,
        stateStr,
        input.rawBody
      ),
      state,
    };
  }

  async verifyCallbackIdentity(
    code: string,
    state: OAuthState,
    stateStr: string,

View on GitHub (pinned to b4c8548c09)

Solutions

  1. Send the exact same client_nonce in the /api/oauth/callback body that you generated and sent to /api/oauth/preflight.
  2. Keep the nonce in the same browser storage (sessionStorage) for the whole redirect round-trip.
  3. Do not start preflight on one device and complete the callback on another — restart the flow instead.
  4. For native clients, persist the nonce alongside the state envelope until the callback completes.

Example fix

// before
const pre = await fetch('/api/oauth/preflight', { method: 'POST', body: JSON.stringify({ provider: 'oidc', client: 'web' }) });
await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state }) }); // nonce lost

// after
const clientNonce = crypto.randomUUID();
const pre = await fetch('/api/oauth/preflight', { method: 'POST', body: JSON.stringify({ provider: 'oidc', client: 'web', client_nonce: clientNonce }) });
// ...redirect round-trip...
await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state, client_nonce: clientNonce }) });
Defensive patterns

Strategy: validation

Validate before calling

// Client: persist the nonce and assert before the callback
const clientNonce = crypto.randomUUID();
sessionStorage.setItem('oauth_client_nonce', clientNonce);
// ...after IdP redirect...
const nonce = sessionStorage.getItem('oauth_client_nonce');
if (!nonce) throw new Error('Login session lost - restart sign-in');
await postCallback({ code, state, client_nonce: nonce });

Try / catch

try {
  await oauth.verifyCallback({ code, stateStr, clientNonce });
} catch (err) {
  if (err instanceof InvalidAuthState) {
    // nonce mismatch: clear stored nonce and restart the flow from preflight
  }
}

Prevention

When it happens

Trigger: Callback request omits client_nonce; the client generates a fresh nonce for the callback instead of reusing the one from preflight; the flow was started in browser/session A and finished in browser/session B (different preflight nonces); a hand-rolled client that does not persist the nonce between the two calls.

Common situations: Custom client integrations and CLI scripts that skip client_nonce; service workers or redirects losing the nonce; users copying the callback URL into another browser; load-balanced web clients that store the nonce in a non-shared session store.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/eb212a708a7f72de. Report an issue: GitHub.