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

  1. Issue a fresh unsubscribe link/token (re-trigger the email or regenerate on request)
  2. Increase the exp TTL in GenerateUnsubscribeJWT to cover realistic email open windows
  3. Sync server clocks (NTP) if drift is the cause
  4. 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

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


AI-assisted analysis of Billionmail/BillionMail@fc36c76c05 (2026-09-05). Data as JSON: /api/errors/13609379d3f930cd. Report an issue: GitHub.