{"record":{"id":"2b178fdc46f00d68","repo":"Billionmail/BillionMail","slug":"jwt-missing-email-claim","errorCode":null,"errorMessage":"JWT missing email claim","messagePattern":"JWT missing email claim","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"core/internal/service/batch_mail/jwt.go","lineNumber":170,"sourceCode":"\t\t// Validate signing method\n\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", token.Header[\"alg\"])\n\t\t}\n\t\treturn []byte(cfg.secret), nil\n\t})\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse JWT: %w\", err)\n\t}\n\n\tif claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {\n\t\tresult := &UnsubscribeClaims{}\n\n\t\t// Extract email\n\t\tif email, ok := claims[\"email\"].(string); ok {\n\t\t\tresult.Email = email\n\t\t} else {\n\t\t\treturn nil, errors.New(\"JWT missing email claim\")\n\t\t}\n\n\t\t// Extract template ID\n\t\tif templateID, ok := claims[\"template_id\"].(float64); ok {\n\t\t\tresult.TemplateId = int(templateID)\n\t\t}\n\n\t\t// Extract task ID\n\t\tif taskID, ok := claims[\"task_id\"].(float64); ok {\n\t\t\tresult.TaskId = int(taskID)\n\t\t}\n\n\t\t// Extract expiration (optional)\n\t\tif exp, ok := claims[\"exp\"].(float64); ok {\n\t\t\t// Check if token has expired\n\t\t\tif time.Now().Unix() > int64(exp) {\n\t\t\t\treturn nil, errors.New(\"JWT has expired\")\n\t\t\t}","sourceCodeStart":152,"sourceCodeEnd":188,"githubUrl":"https://github.com/Billionmail/BillionMail/blob/fc36c76c050c3775c5e899faf7403cf0262d2744/core/internal/service/batch_mail/jwt.go#L152-L188","documentation":"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.","triggerScenarios":"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).","commonSituations":"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'.","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"],"exampleFix":"// before\nclaims := jwt.MapClaims{\"template_id\": id, \"group_id\": gid}\n// after\nclaims := jwt.MapClaims{\"email\": email, \"template_id\": id, \"group_id\": gid}","handlingStrategy":"validation","validationCode":"// issuer-side check before signing\nif email == \"\" {\n\treturn fmt.Errorf(\"cannot sign unsubscribe token without email\")\n}","typeGuard":"func claimString(claims jwt.MapClaims, key string) (string, bool) {\n\ts, ok := claims[key].(string)\n\treturn s, ok && s != \"\"\n}","tryCatchPattern":"claims, err := ParseUnsubscribeJWT(tok)\nif err != nil {\n\tif strings.Contains(err.Error(), \"missing email claim\") {\n\t\t// treat as invalid legacy token: regenerate link\n\t}\n\treturn err\n}","preventionTips":["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"],"tags":["jwt","go","claims","auth"],"backgroundTag":"jwt-missing-claim","analyzedSha":"fc36c76c050c3775c5e899faf7403cf0262d2744","analyzedAt":"2026-09-05T21:28:54.019Z","contentChangedAt":"2026-09-05T21:28:54.019Z","schemaVersion":2},"datasetVersion":"2026-09-12T22:17:10.623Z"}