{"record":{"id":"13609379d3f930cd","repo":"Billionmail/BillionMail","slug":"jwt-has-expired","errorCode":null,"errorMessage":"JWT has expired","messagePattern":"JWT has expired","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"core/internal/service/batch_mail/jwt.go","lineNumber":187,"sourceCode":"\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}\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 {","sourceCodeStart":169,"sourceCodeEnd":205,"githubUrl":"https://github.com/Billionmail/BillionMail/blob/fc36c76c050c3775c5e899faf7403cf0262d2744/core/internal/service/batch_mail/jwt.go#L169-L205","documentation":"Manual expiry check: after parsing, if the 'exp' claim exists and time.Now().Unix() exceeds it, the function returns this error. golang-jwt already validates exp during Parse by default, but because these are MapClaims with a custom parser path this explicit check is the guard the code relies on.","triggerScenarios":"User clicks an unsubscribe link whose embedded JWT's exp timestamp is in the past; long-lived emails opened after token TTL elapsed; clocks skewed between signer and verifier.","commonSituations":"Cold subscribers opening campaigns months later; token TTL set too short (minutes instead of the email's realistic lifetime); server clock drift after NTP failure or container restart.","solutions":["Issue a fresh unsubscribe link/token (re-trigger the email or regenerate on request)","Increase the exp TTL in GenerateUnsubscribeJWT to cover realistic email open windows","Sync server clocks (NTP) if drift is the cause","Decide on UX: redirect expired links to a 'preferences center' instead of erroring"],"exampleFix":"// before\nexp := time.Now().Add(24 * time.Hour).Unix()\n// after\nexp := time.Now().Add(30 * 24 * time.Hour).Unix()","handlingStrategy":"try-catch","validationCode":"// pre-check on client/handler side is impossible without decoding; decode payload for UX warning:\nparts := strings.Split(tokenString, \".\")\nif len(parts) == 3 {\n\tif b, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {\n\t\tvar c map[string]float64\n\t\tif json.Unmarshal(b, &c) == nil && c[\"exp\"] > 0 && float64(time.Now().Unix()) > c[\"exp\"] {\n\t\t\t// token already expired — offer link regeneration\n\t\t}\n\t}\n}","typeGuard":"func isExpired(claims jwt.MapClaims) bool {\n\texp, ok := claims[\"exp\"].(float64)\n\treturn ok && time.Now().Unix() > int64(exp)\n}","tryCatchPattern":"claims, err := ParseUnsubscribeJWT(tok)\nswitch {\ncase err == nil:\n\t// proceed\ncase strings.Contains(err.Error(), \"expired\"):\n\t// show 'link expired, update preferences here' page instead of raw error\ndefault:\n\treturn err\n}","preventionTips":["Set exp TTLs long enough for email latency (weeks, not hours)","Monitor server clock sync (NTP) in containers/VMs","Provide a friendly 'expired link' recovery path in the UI","Add a test asserting an artificially expired token returns this error"],"tags":["jwt","go","expiry","auth"],"backgroundTag":"jwt-token-expired","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"}