Billionmail/BillionMail · error

JWT missing email claim

Error message

JWT missing email claim

What it means

After successful jwt.Parse and validation, the code requires the 'email' MapClaims entry to be a string; if absent or of another JSON type it returns this error. Unlike template_id/group_id, email is treated as mandatory for unsubscribe tokens.

Source

Thrown at core/internal/service/batch_mail/jwt.go:170

		// Validate signing method
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(cfg.secret), nil
	})

	if err != nil {
		return nil, fmt.Errorf("failed to parse JWT: %w", err)
	}

	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		result := &UnsubscribeClaims{}

		// Extract email
		if email, ok := claims["email"].(string); ok {
			result.Email = email
		} else {
			return nil, errors.New("JWT missing email claim")
		}

		// Extract template ID
		if templateID, ok := claims["template_id"].(float64); ok {
			result.TemplateId = int(templateID)
		}

		// Extract task ID
		if taskID, ok := claims["task_id"].(float64); ok {
			result.TaskId = int(taskID)
		}

		// Extract expiration (optional)
		if exp, ok := claims["exp"].(float64); ok {
			// Check if token has expired
			if time.Now().Unix() > int64(exp) {
				return nil, errors.New("JWT has expired")
			}

View on GitHub (pinned to fc36c76c05)

Solutions

  1. Regenerate the token ensuring GenerateUnsubscribeJWT includes claims["email"] as a string
  2. Audit any external/custom token producer to emit the 'email' claim as a string
  3. If legacy tokens must be supported, fall back to claims["sub"] when email is missing

Example fix

// before
claims := jwt.MapClaims{"template_id": id, "group_id": gid}
// after
claims := jwt.MapClaims{"email": email, "template_id": id, "group_id": gid}
Defensive patterns

Strategy: validation

Validate before calling

// issuer-side check before signing
if email == "" {
	return fmt.Errorf("cannot sign unsubscribe token without email")
}

Type guard

func claimString(claims jwt.MapClaims, key string) (string, bool) {
	s, ok := claims[key].(string)
	return s, ok && s != ""
}

Try / catch

claims, err := ParseUnsubscribeJWT(tok)
if err != nil {
	if strings.Contains(err.Error(), "missing email claim") {
		// treat as invalid legacy token: regenerate link
	}
	return err
}

Prevention

When it happens

Trigger: A token signed without an 'email' claim, with email as a non-string (e.g. number, object), or signed by a different flow (e.g. subscribe-confirm tokens have email too but are parsed with a different secret — if secrets match, wrong-flow tokens can slip into claims checks).

Common situations: Hand-rolled token generators omitting email; older tokens created before the email claim was added still circulating in previously sent emails; another service issuing tokens with 'sub' instead of 'email'.

Related errors


AI-assisted analysis of Billionmail/BillionMail@fc36c76c05 (2026-09-05). Data as JSON: /api/errors/2b178fdc46f00d68. Report an issue: GitHub.