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
- Regenerate the token ensuring GenerateUnsubscribeJWT includes claims["email"] as a string
- Audit any external/custom token producer to emit the 'email' claim as a string
- 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
- Always set claims["email"] as a string when generating tokens
- Add a round-trip test: GenerateUnsubscribeJWT → ParseUnsubscribeJWT
- Version tokens (add a 'v' claim) so legacy-schema tokens can be detected
- Document the required claim schema next to the generator
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
- JWT missing or invalid group_id claim
- invalid token claims
- unexpected signing method: %v
- failed to parse JWT: %w
- JWT has expired
AI-assisted analysis of Billionmail/BillionMail@fc36c76c05 (2026-09-05).
Data as JSON: /api/errors/2b178fdc46f00d68.
Report an issue: GitHub.