{"record":{"id":"b5c2fe6eaa8460ec","repo":"Billionmail/BillionMail","slug":"jwt-missing-or-invalid-group-id-claim","errorCode":null,"errorMessage":"JWT missing or invalid group_id claim","messagePattern":"JWT missing or invalid group_id claim","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"core/internal/service/batch_mail/jwt.go","lineNumber":195,"sourceCode":"\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}\n\t\t\tresult.RegisteredClaims.ExpiresAt = jwt.NewNumericDate(time.Unix(int64(exp), 0))\n\t\t}\n\t\t// Extract group ID\n\t\tif groupID, ok := claims[\"group_id\"].(float64); ok {\n\t\t\tresult.GroupId = int(groupID)\n\t\t} else {\n\t\t\treturn nil, errors.New(\"JWT missing or invalid group_id claim\")\n\t\t}\n\n\t\tg.Log().Debug(context.Background(), \"JWT parsed successfully: %+v\", result)\n\t\treturn result, nil\n\t}\n\n\treturn nil, errors.New(\"invalid token claims\")\n}\n\ntype SubscribeConfirmClaims struct {\n\tEmail      string `json:\"email\"`\n\tGroupToken string `json:\"group_token\"`\n\tjwt.RegisteredClaims\n}\n\nfunc getSubscribeConfirmConfig() *jwtConfig {\n\tonce.Do(func() {\n\t\tconfig = loadSubscribeConfirmConfig()","sourceCodeStart":177,"sourceCodeEnd":213,"githubUrl":"https://github.com/Billionmail/BillionMail/blob/fc36c76c050c3775c5e899faf7403cf0262d2744/core/internal/service/batch_mail/jwt.go#L177-L213","documentation":"The 'group_id' claim is mandatory for unsubscribe tokens: it must be present and a JSON number (MapClaims decodes numbers as float64). Missing or wrong type aborts parsing. Note the asymmetry with template_id, which is optional.","triggerScenarios":"Token signed without group_id, with group_id as a string (e.g. \"12\"), or with a null value; tokens from a different claim schema hitting ParseUnsubscribeJWT.","commonSituations":"Custom/legacy token generators encoding group_id as a string; a schema change on the issuing side; tokens minted by the subscribe-confirm flow (which uses group_token, not group_id) parsed by the wrong function if secrets coincide.","solutions":["Ensure the issuer includes claims[\"group_id\"] as a number (int in Go encodes as JSON number)","Convert string group IDs to int before signing: gid, _ := strconv.Atoi(s)","Verify the caller is using ParseSubscribeConfirmJWT/ParseUnsubscribeJWT matched to the token's producer flow"],"exampleFix":"// before\nclaims[\"group_id\"] = strconv.Itoa(groupID)\n// after\nclaims[\"group_id\"] = groupID","handlingStrategy":"validation","validationCode":"// issuer-side guard before signing\nif groupID <= 0 {\n\treturn fmt.Errorf(\"cannot sign unsubscribe token without group_id\")\n}","typeGuard":"func claimInt(claims jwt.MapClaims, key string) (int, bool) {\n\tf, ok := claims[key].(float64) // encoding/json numbers decode as float64\n\treturn int(f), ok\n}","tryCatchPattern":"claims, err := ParseUnsubscribeJWT(tok)\nif err != nil {\n\tif strings.Contains(err.Error(), \"group_id\") {\n\t\treturn nil, ErrBadTokenSchema\n\t}\n\treturn err\n}","preventionTips":["Never stringify numeric claims (group_id must be JSON number)","Round-trip test generator→parser for every claim field","Match parser to producer flow: unsubscribe claims vs subscribe-confirm claims","Pin a schema doc/constants file for claim keys"],"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"}