toeverything/AFFiNE · error · EmailTokenNotFound
email_token_not_found
email_token_not_found
Error message
The email token provided is not found.
What it means
POST /api/auth/magic-link parses the JSON body with MagicLinkBodySchema - a strict object { email: string (max 320), token: string (1..512), client_nonce?: string (1..512) }. Any parse failure is deliberately mapped to EmailTokenNotFound (email_token_not_found) instead of a field-level validation error, to avoid leaking which part of the credential is wrong. Because the schema is .strict(), unknown extra fields also fail.
Solutions
- Send exactly { email, token, client_nonce? } as JSON with Content-Type: application/json
- Extract token from the actual magic-link URL parameter, not the whole URL
- Check field sizes: email <= 320 chars, token 1..512 chars
- Remove legacy extra fields (verifyToken, challenge) from the body
Example fix
// before
await fetch('/api/auth/magic-link', {
method: 'POST',
body: JSON.stringify({ email, code: otp, verifyToken }), // wrong key + legacy field
});
// after
await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, token: otp }), // client_nonce only if you passed one at send time
}); Defensive patterns
Strategy: validation
Validate before calling
import { z } from 'zod';
const MagicLinkBody = z.object({
email: z.string().min(1).max(320),
token: z.string().min(1).max(512),
client_nonce: z.string().min(1).max(512).optional(),
}).strict();
// throws locally with a useful message instead of opaque email_token_not_found
const body = MagicLinkBody.parse({ email, token, client_nonce }); Try / catch
try {
await post('/auth/magic-link', body);
} catch (e) {
if (isAffineErrorCode(e, 'email_token_not_found')) {
showFormError('The sign-in code is missing or malformed. Open the newest email.');
} else throw e;
} Prevention
- Mirror server body schemas (zod) in the client and parse before sending
- Send JSON with an explicit content-type header from a shared request helper
- Extract the token parameter from the link URL instead of pasting the whole URL
When it happens
Trigger: Missing the token field; sending legacy fields like verifyToken or challenge (rejected by .strict()); a token longer than 512 chars or email longer than 320; a Content-Type that does not parse to a JSON object (form-encoded string, plain text); wrong field name such as code instead of token.
Common situations: Older clients still posting captcha fields that were moved to headers; hand-rolled fetch calls with typo'd keys; tests sending urlencoded bodies; copy-pasting a magic-link URL instead of extracting the token parameter.
Related errors
- action_forbidden
- captcha_verification_failed
- invalid_auth_state
- invalid_checkout_parameters
- Invalid config for module
AI-assisted analysis of toeverything/AFFiNE@2af30773ae (2026-08-18).
Data as JSON: /api/errors/4b648eb1247baf30.
Report an issue: GitHub.
Appendix: source
Thrown at packages/backend/server/src/core/auth/controller.ts:352
this.assertSessionMutationAuthorized(req, session);
await this.authSessions.revoke(
parsedSessionId.data,
'user_action',
user.id
);
return {};
}
@Public()
@UseNamedGuard('version')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Body() body?: unknown
) {
const credential = MagicLinkBodySchema.safeParse(body);
if (!credential.success) throw new EmailTokenNotFound();
const { email, token: otp, client_nonce: clientNonce } = credential.data;
if (!email) throw new EmailTokenNotFound();
validators.assertValidEmail(email);
const result = await this.magicLink.complete(
email,
otp,
clientNonce,
this.sessionIssuer.target(req)
);
this.sessionIssuer.apply(res, result);
res.send({ id: result.user.id, exchangeCode: result.exchangeCode });
}
@UseNamedGuard('version')
@Throttle('default', { limit: 1200 })
@Public()
@Get('/session')
@Header('Cache-Control', 'no-store')View on GitHub (pinned to 2af30773ae)