{"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/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/core/auth/magic-link.ts#L93-L129","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Ensure the same clientNonce is captured at send time and replayed at verify time (persist it in sessionStorage/localStorage keyed by email).","Complete the verification in the same browser tab/device that initiated the send.","If state was lost, re-trigger send to obtain a fresh OTP+nonce pair before verifying.","Pass clientNonce consistently from the client through both API calls."],"exampleFix":"// before: nonce lost between calls\nawait send(email);\n// ... page reload ...\nawait verify(email, otp); // missing nonce -> invalid_auth_state\n\n// after: persist and replay\nconst nonce = crypto.randomUUID();\nsessionStorage.setItem(`magic-nonce:${email}`, nonce);\nawait send(email, '/magic-link', nonce);\n// later:\nconst nonce = sessionStorage.getItem(`magic-nonce:${email}`) ?? undefined;\nawait verify(email, otp, nonce);","handlingStrategy":"validation","validationCode":"const nonce = crypto.randomUUID();\nsessionStorage.setItem(`magic-nonce:${email}`, nonce);\nawait send(email, '/magic-link', nonce);\n// later, in the same device/session:\nconst storedNonce = sessionStorage.getItem(`magic-nonce:${email}`);\nif (!storedNonce) throw new Error('nonce lost — restart flow');\nawait verify(email, otp, storedNonce);","typeGuard":"function isInvalidAuthState(err: unknown): boolean {\n  return (\n    !!err &&\n    typeof err === 'object' &&\n    (err as { code?: string }).code === 'invalid_auth_state'\n  );\n}","tryCatchPattern":"try {\n  await verify(email, otp, nonce);\n} catch (err) {\n  if (isInvalidAuthState(err)) {\n    showHint('Use the same device that requested the code, then retry.');\n    await restartMagicLinkFlow(email);\n    return;\n  }\n  throw err;\n}","preventionTips":["Persist the clientNonce in sessionStorage keyed by email across the send->verify round trip.","Complete verification on the same device/tab that initiated send.","Generate a fresh nonce per send and discard after successful verify.","On any page reload between send and verify, re-initiate the flow."],"tags":["auth","magic-link","otp","nonce","anti-replay"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}