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

  1. Send `{ email, token }` where `token` is the OTP from the magic link (1–512 chars).
  2. Ensure the magic-link URL preserves the OTP/query params through the redirect chain.
  3. 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

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.