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
- Call sendChangePasswordEmail again to mint a fresh token and point the user to the new link
- Make the submit single-flight: disable the change-password form while the mutation is in flight and after success
- Verify the userId sent with the mutation belongs to the same account that requested the email
- 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
- Treat reset links as single-use: disable the change-password form after the first submit
- Send the userId that matches the account which received the email
- Keep the ChangePassword token TTL comfortably larger than worst-case email delivery time
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)