toeverything/AFFiNE · error · InvalidEmailToken
invalid_email_token
invalid_email_token
Error message
An invalid email token provided.
What it means
During MagicLinkAuthService.verify, magicLinkOtp.consume returns ok:false for any reason OTHER than nonce_mismatch. consume fails when the (email, otp) pair is not found, was already consumed (one-time use), or has expired. The server throws InvalidEmailToken rather than InvalidAuthState because the failure is about the credential itself, not the client binding.
Source
Thrown at packages/backend/server/src/core/auth/magic-link.ts:113
}
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);
return { userId: user.id, method: 'magic_link' };
}View on GitHub (pinned to 26c515e050)
Solutions
- Request a fresh magic link / OTP and enter it promptly within the 30-minute window.
- Make sure the OTP is not consumed elsewhere before this verify (no double-submission).
- Copy the code exactly as delivered; avoid whitespace/typing errors.
- If delivery is consistently slow, investigate mail transport latency so the code arrives before expiry.
Defensive patterns
Strategy: try-catch
Type guard
function isInvalidEmailToken(err: unknown): boolean {
return (
!!err &&
typeof err === 'object' &&
(err as { code?: string }).code === 'invalid_email_token'
);
} Try / catch
try {
await verify(email, otp, nonce);
} catch (err) {
if (isInvalidEmailToken(err)) {
showUser('The code is invalid or expired. Request a new one.');
return;
}
throw err;
} Prevention
- Enter the OTP exactly as delivered, within the 30-minute window.
- Prevent double-submit of the same OTP (disable the button after first submit).
- Re-request a new magic link rather than retrying the same expired code.
- Track delivery latency to ensure codes arrive before expiry.
When it happens
Trigger: User submits a wrong, typo'd, or already-used OTP for the given email. The OTP row expired past its otpExpiresAt (30-minute token TTL window). The OTP was consumed by an earlier successful verify attempt.
Common situations: User retried an old code, typed it manually and mistyped, or clicked the same magic link twice (second click finds the OTP already consumed). Slow email delivery means the user enters a code after it expired.
Related errors
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/9be0d000290d87db.
Report an issue: GitHub.