toeverything/AFFiNE · error · InvalidEmailToken

invalid_email_token

invalid_email_token

Error message

An invalid email token provided.

What it means

Thrown by the changePassword GraphQL mutation when verificationToken.verify(TokenType.ChangePassword, token, { credential: userId }) returns false. The token must be of type ChangePassword, carry the passed userId as its credential, be unexpired, and be unconsumed. Set-password and change-password share one token type, so any earlier use of the token burns it.

Solutions

  1. Call sendChangePasswordEmail again to mint a fresh token and point the user to the new link
  2. Make the submit single-flight: disable the change-password form while the mutation is in flight and after success
  3. Verify the userId sent with the mutation belongs to the same account that requested the email
  4. If users routinely outlive the TTL, raise the ChangePassword token expiry in the verificationToken model config

Example fix

// before
await client.request(changePasswordMutation, { userId, token, newPassword });

// after
try {
  await client.request(changePasswordMutation, { userId, token, newPassword });
} catch (e) {
  if (gqlCode(e) === 'invalid_email_token') {
    await client.request(sendChangePasswordEmailMutation, { callbackUrl });
    throw new Error('Reset link expired. A new email has been sent.');
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Type guard

function isInvalidEmailToken(e: unknown): boolean {
  return (
    typeof e === 'object' &&
    e !== null &&
    'extensions' in e &&
    (e as { extensions?: { code?: string } }).extensions?.code === 'invalid_email_token'
  );
}

Try / catch

Catch the GraphQL error, inspect extensions.code === 'invalid_email_token', and translate it into a user-facing 'link expired' message plus a fresh sendChangePasswordEmail call. Rethrow every other code unchanged.

Prevention

When it happens

Trigger: Calling changePassword(userId, token, newPassword) with a token whose TTL elapsed, a token already consumed by a previous changePassword call, a token minted for a different user, or a truncated/mangled token string (e.g. URL encoding stripped characters).

Common situations: User clicks the reset link twice and the second request reuses the consumed token; a stale email is opened after the password was already changed; the frontend sends the userId of a different signed-in account; token TTL configured shorter than real email delivery latency.

Related errors


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

Appendix: 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 b4c8548c09)