toeverything/AFFiNE · error · InvalidEmailToken

invalid_email_token

invalid_email_token

Error message

An invalid email token provided.

What it means

In the public changePassword mutation, after confirming userId is present, the server verifies the ChangePassword verification token via models.verificationToken.verify(TokenType.ChangePassword, token, { credential: userId }). If verify returns falsy — token not found, credential mismatch, or past expiresAt — InvalidEmailToken is thrown. NOTE: set-password and change-password share the same token type, so either flow's token is acceptable here.

Source

Thrown at packages/backend/server/src/core/auth/resolver.ts:122

    @Args('token') token: string,
    @Args('newPassword') newPassword: string,
    @Args('userId', { type: () => String, nullable: true }) userId?: string
  ) {
    if (!userId) {
      throw new LinkExpired();
    }

    // NOTE: Set & Change password are using the same token type.
    const valid = await this.models.verificationToken.verify(
      TokenType.ChangePassword,
      token,
      {
        credential: userId,
      }
    );

    if (!valid) {
      throw new InvalidEmailToken();
    }

    await this.auth.changePasswordAndRevokeSessions(userId, newPassword);

    return true;
  }

  @Mutation(() => UserType)
  async changeEmail(
    @CurrentUser() user: CurrentUser,
    @Args('token') token: string,
    @Args('email') email: string
  ) {
    // @see [sendChangeEmail]
    const valid = await this.models.verificationToken.verify(
      TokenType.VerifyEmail,
      token,
      {

View on GitHub (pinned to 26c515e050)

Solutions

  1. Request a new change/set-password email and use the new link promptly.
  2. Use the token exactly once, immediately after receipt, within its TTL.
  3. Confirm the userId and token in the link correspond to the same account.
  4. If self-hosted, verify verification_token rows are not pruned prematurely.
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 changePassword({ userId, token, newPassword });
} catch (err) {
  if (isInvalidEmailToken(err)) {
    showUser('This reset link has expired.');
    redirectToRequestReset();
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: changePassword called with a token that is wrong, already used (one-time), expired, or whose stored credential does not equal the supplied userId. The verification_token row was deleted.

Common situations: User clicked an old reset link whose token expired. The link was already used (token consumed on a previous successful change). Token/userId mismatch because the link was crafted for a different account.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/9d8ad81e8d56c4b9. Report an issue: GitHub.