toeverything/AFFiNE · error · EmailTokenNotFound
email_token_not_found
email_token_not_found
Error message
The email token provided is not found.
What it means
Thrown by `POST /api/auth/magic-link` when the body fails `MagicLinkBodySchema.safeParse`. The schema is `{ email, token, client_nonce? }.strict()` with `token` being a 1–512 char string, so a missing/empty token, missing email, extra fields, or wrong types all fail. HTTP 400, reported as `email_token_not_found` rather than leaking which field is wrong.
Source
Thrown at packages/backend/server/src/core/auth/controller.ts:339
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 identity = await this.magicLink.verify(email, otp, clientNonce);
const { exchangeCode } = await this.sessionIssuer.issue(req, res, identity);
res.send({ id: identity.userId, exchangeCode });
}
@UseNamedGuard('version')
@Throttle('default', { limit: 1200 })
@Public()
@Get('/session')
@Header('Cache-Control', 'no-store')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
return { user };
}
private assertSessionMutationAuthorized(View on GitHub (pinned to 26c515e050)
Solutions
- Send `{ email, token }` where `token` is the OTP from the magic link (1–512 chars).
- Ensure the magic-link URL preserves the OTP/query params through the redirect chain.
- If the token was consumed or expired (>10 min), request a new magic link.
Example fix
// before
fetch('/api/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
// after
fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, token: otp }),
}); Defensive patterns
Strategy: validation
Validate before calling
import { z } from 'zod';
const Body = z.object({ email: z.string().min(1).max(320), token: z.string().min(1).max(512) }).strict();
const parsed = Body.safeParse(payload);
if (!parsed.success) throw new Error('email and token required');
await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(parsed.data),
}); Type guard
function isMagicLinkBody(v: unknown): v is { email: string; token: string } {
const o = v as any;
return !!o && typeof o.email === 'string' && o.email.length >= 1 && o.email.length <= 320 &&
typeof o.token === 'string' && o.token.length >= 1 && o.token.length <= 512;
} Prevention
- Preserve the OTP/token through the magic-link redirect chain.
- Match the strict schema exactly — only `email` and `token` (optional `client_nonce`).
- Request a new magic link if the token is older than its TTL.
When it happens
Trigger: Posting a magic-link completion without the `token` (OTP), without `email`, with an empty token, or with extra fields the strict schema rejects.
Common situations: User clicks the magic link but the OTP query param was stripped, the deep-link handler drops the token, or a client sends `{ otp }` instead of `{ token }`.
Related errors
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/4b648eb1247baf30.
Report an issue: GitHub.