Billionmail/BillionMail · warning
JWT has expired
Error message
JWT has expired
What it means
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.
Source
Thrown at core/internal/service/batch_mail/jwt.go:187
} 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")
}
result.RegisteredClaims.ExpiresAt = jwt.NewNumericDate(time.Unix(int64(exp), 0))
}
// Extract group ID
if groupID, ok := claims["group_id"].(float64); ok {
result.GroupId = int(groupID)
} else {
return nil, errors.New("JWT missing or invalid group_id claim")
}
g.Log().Debug(context.Background(), "JWT parsed successfully: %+v", result)
return result, nil
}
return nil, errors.New("invalid token claims")
}
type SubscribeConfirmClaims struct {View on GitHub (pinned to fc36c76c05)
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
Example fix
// before exp := time.Now().Add(24 * time.Hour).Unix() // after exp := time.Now().Add(30 * 24 * time.Hour).Unix()
Defensive patterns
Strategy: try-catch
Validate before calling
// pre-check on client/handler side is impossible without decoding; decode payload for UX warning:
parts := strings.Split(tokenString, ".")
if len(parts) == 3 {
if b, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
var c map[string]float64
if json.Unmarshal(b, &c) == nil && c["exp"] > 0 && float64(time.Now().Unix()) > c["exp"] {
// token already expired — offer link regeneration
}
}
} Type guard
func isExpired(claims jwt.MapClaims) bool {
exp, ok := claims["exp"].(float64)
return ok && time.Now().Unix() > int64(exp)
} Try / catch
claims, err := ParseUnsubscribeJWT(tok)
switch {
case err == nil:
// proceed
case strings.Contains(err.Error(), "expired"):
// show 'link expired, update preferences here' page instead of raw error
default:
return err
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- unexpected signing method: %v
- failed to parse JWT: %w
- JWT missing email claim
- JWT missing or invalid group_id claim
- invalid token claims
AI-assisted analysis of Billionmail/BillionMail@fc36c76c05 (2026-09-05).
Data as JSON: /api/errors/13609379d3f930cd.
Report an issue: GitHub.