{"record":{"id":"975ad2ff554ed4c1","repo":"toeverything/AFFiNE","slug":"invalid-auth-state-975ad2","errorCode":"invalid_auth_state","errorMessage":"Invalid auth state. You might start the auth progress from another device.","messagePattern":"Invalid auth state\\. You might start the auth progress from another device\\.","errorType":"exception","errorClass":"InvalidAuthState","httpStatus":400,"severity":"error","filePath":"packages/backend/server/src/core/auth/magic-link.ts","lineNumber":111,"sourceCode":"\n    return { email };\n  }\n\n  async verify(\n    email: string,\n    otp: string,\n    clientNonce?: string\n  ): Promise<VerifiedIdentity> {\n    validators.assertValidEmail(email);\n\n    const consumed = await this.models.magicLinkOtp.consume(\n      email,\n      otp,\n      clientNonce\n    );\n    if (!consumed.ok) {\n      if (consumed.reason === 'nonce_mismatch') {\n        throw new InvalidAuthState();\n      }\n      throw new InvalidEmailToken();\n    }\n\n    const tokenRecord = await this.models.verificationToken.verify(\n      TokenType.SignIn,\n      consumed.token,\n      {\n        credential: email,\n      }\n    );\n\n    if (!tokenRecord) {\n      throw new InvalidEmailToken();\n    }\n\n    const user = await this.models.user.fulfill(email);\n","sourceCodeStart":93,"sourceCodeEnd":129,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/magic-link.ts#L93-L129","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Complete verification in the same client/browser session that requested the link, reusing the identical client_nonce","Persist client_nonce (localStorage/session storage) from send until verify","If the flow must continue on another device, restart: request a fresh link there with its own nonce","Never retry verification with a newly randomized nonce after a failure"],"exampleFix":"// before\nconst nonce = crypto.randomUUID(); // new nonce each call -> mismatch\nawait sendMagicLink(email, nonce);\nawait verifyMagicLink(email, token, crypto.randomUUID());\n\n// after\nconst nonce = crypto.randomUUID();\nsessionStorage.setItem('magic_link_nonce', nonce);\nawait sendMagicLink(email, nonce);\nawait verifyMagicLink(email, token, sessionStorage.getItem('magic_link_nonce') ?? undefined);","handlingStrategy":"try-catch","validationCode":"function getClientNonce(): string | undefined {\n  let nonce = sessionStorage.getItem('magic_link_nonce');\n  if (!nonce) {\n    nonce = crypto.randomUUID();\n    sessionStorage.setItem('magic_link_nonce', nonce);\n  }\n  return nonce; // same nonce for send AND verify\n}","typeGuard":null,"tryCatchPattern":"try {\n  await verifyMagicLink(email, token, getClientNonce());\n} catch (e) {\n  if (isAffineErrorCode(e, 'invalid_auth_state')) {\n    sessionStorage.removeItem('magic_link_nonce');\n    await restartMagicLinkFlow(); // fresh request with a fresh nonce on THIS device\n  } else throw e;\n}","preventionTips":["Persist client_nonce from request to verification in the same storage scope","Restart the whole flow on device switches instead of carrying tokens across"],"tags":["auth","magic-link","nonce","device-binding","otp"],"backgroundTag":"nonce-mismatch","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}