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
- Request a new change/set-password email and use the new link promptly.
- Use the token exactly once, immediately after receipt, within its TTL.
- Confirm the userId and token in the link correspond to the same account.
- 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
- Use each reset token once, immediately after receiving the email.
- Re-request the reset email when the token has expired.
- Ensure userId and token in the link match the same account.
- Do not prune verification_token rows prematurely on self-hosted deploys.
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.