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

During MagicLinkAuthService.verify, magicLinkOtp.consume returns ok:false with reason 'nonce_mismatch', meaning the OTP is valid for the email but the clientNonce provided at verify time differs from the nonce stored when the OTP was created (upserted during send). The server reports InvalidAuthState ('You might start the auth progress from another device') rather than revealing the OTP was wrong. This binds OTP consumption to the originating client to prevent OTP replay from a different session.

Source

Thrown at packages/backend/server/src/core/auth/magic-link.ts:111

    return { email };
  }

  async verify(
    email: string,
    otp: string,
    clientNonce?: string
  ): Promise<VerifiedIdentity> {
    validators.assertValidEmail(email);

    const consumed = await this.models.magicLinkOtp.consume(
      email,
      otp,
      clientNonce
    );
    if (!consumed.ok) {
      if (consumed.reason === 'nonce_mismatch') {
        throw new InvalidAuthState();
      }
      throw new InvalidEmailToken();
    }

    const tokenRecord = await this.models.verificationToken.verify(
      TokenType.SignIn,
      consumed.token,
      {
        credential: email,
      }
    );

    if (!tokenRecord) {
      throw new InvalidEmailToken();
    }

    const user = await this.models.user.fulfill(email);

View on GitHub (pinned to 26c515e050)

Solutions

  1. Ensure the same clientNonce is captured at send time and replayed at verify time (persist it in sessionStorage/localStorage keyed by email).
  2. Complete the verification in the same browser tab/device that initiated the send.
  3. If state was lost, re-trigger send to obtain a fresh OTP+nonce pair before verifying.
  4. Pass clientNonce consistently from the client through both API calls.

Example fix

// before: nonce lost between calls
await send(email);
// ... page reload ...
await verify(email, otp); // missing nonce -> invalid_auth_state

// after: persist and replay
const nonce = crypto.randomUUID();
sessionStorage.setItem(`magic-nonce:${email}`, nonce);
await send(email, '/magic-link', nonce);
// later:
const nonce = sessionStorage.getItem(`magic-nonce:${email}`) ?? undefined;
await verify(email, otp, nonce);
Defensive patterns

Strategy: validation

Validate before calling

const nonce = crypto.randomUUID();
sessionStorage.setItem(`magic-nonce:${email}`, nonce);
await send(email, '/magic-link', nonce);
// later, in the same device/session:
const storedNonce = sessionStorage.getItem(`magic-nonce:${email}`);
if (!storedNonce) throw new Error('nonce lost — restart flow');
await verify(email, otp, storedNonce);

Type guard

function isInvalidAuthState(err: unknown): boolean {
  return (
    !!err &&
    typeof err === 'object' &&
    (err as { code?: string }).code === 'invalid_auth_state'
  );
}

Try / catch

try {
  await verify(email, otp, nonce);
} catch (err) {
  if (isInvalidAuthState(err)) {
    showHint('Use the same device that requested the code, then retry.');
    await restartMagicLinkFlow(email);
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: The magic-link send was triggered from one client/tab (storing one clientNonce), and verify is invoked from a different client/tab/device supplying a different (or missing) clientNonce. Also if the SPA loses the in-memory nonce between send and verify (page reload cleared state).

Common situations: User requested the magic link on desktop but clicked/entered the code on mobile. A full page reload between send and verify dropped the clientNonce. Two tabs each generated their own nonce.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/975ad2ff554ed4c1. Report an issue: GitHub.