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
- 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
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
- Persist client_nonce from request to verification in the same storage scope
- Restart the whole flow on device switches instead of carrying tokens across
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)