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 magic-link verification, models.magicLinkOtp.consume(email, otp, clientNonce) compares the client_nonce sent at verification with the one bound when the link was requested. On reason 'nonce_mismatch' the service throws InvalidAuthState (invalid_auth_state) - the literal case behind the 'started the auth progress from another device' message: the one-time secret is tied to the client instance that requested it.

Solutions

  1. Complete verification in the same client/browser session that requested the link, reusing the identical client_nonce
  2. Persist client_nonce (localStorage/session storage) from send until verify
  3. If the flow must continue on another device, restart: request a fresh link there with its own nonce
  4. Never retry verification with a newly randomized nonce after a failure

Example fix

// before
const nonce = crypto.randomUUID(); // new nonce each call -> mismatch
await sendMagicLink(email, nonce);
await verifyMagicLink(email, token, crypto.randomUUID());

// after
const nonce = crypto.randomUUID();
sessionStorage.setItem('magic_link_nonce', nonce);
await sendMagicLink(email, nonce);
await verifyMagicLink(email, token, sessionStorage.getItem('magic_link_nonce') ?? undefined);
Defensive patterns

Strategy: try-catch

Validate before calling

function getClientNonce(): string | undefined {
  let nonce = sessionStorage.getItem('magic_link_nonce');
  if (!nonce) {
    nonce = crypto.randomUUID();
    sessionStorage.setItem('magic_link_nonce', nonce);
  }
  return nonce; // same nonce for send AND verify
}

Try / catch

try {
  await verifyMagicLink(email, token, getClientNonce());
} catch (e) {
  if (isAffineErrorCode(e, 'invalid_auth_state')) {
    sessionStorage.removeItem('magic_link_nonce');
    await restartMagicLinkFlow(); // fresh request with a fresh nonce on THIS device
  } else throw e;
}

Prevention

When it happens

Trigger: Requesting the magic link in browser/device A (which sent client_nonce A) but submitting the token from device B with a different or absent client_nonce; two tabs of the same app generating different nonces; frontend that does not persist the nonce between the send and verify steps; clearing storage between steps.

Common situations: User emails themselves the link and opens it on another machine; native app reinstalled (nonce storage lost); a web app regenerating a random nonce per request instead of per flow; race where two sign-in attempts overlap.

Related errors


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

Appendix: 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 b4c8548c09)