Billionmail/BillionMail · error

JWT missing or invalid group_id claim

Error message

JWT missing or invalid group_id claim

What it means

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.

Source

Thrown at core/internal/service/batch_mail/jwt.go:195

		// 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 {
	Email      string `json:"email"`
	GroupToken string `json:"group_token"`
	jwt.RegisteredClaims
}

func getSubscribeConfirmConfig() *jwtConfig {
	once.Do(func() {
		config = loadSubscribeConfirmConfig()

View on GitHub (pinned to fc36c76c05)

Solutions

  1. Ensure the issuer includes claims["group_id"] as a number (int in Go encodes as JSON number)
  2. Convert string group IDs to int before signing: gid, _ := strconv.Atoi(s)
  3. Verify the caller is using ParseSubscribeConfirmJWT/ParseUnsubscribeJWT matched to the token's producer flow

Example fix

// before
claims["group_id"] = strconv.Itoa(groupID)
// after
claims["group_id"] = groupID
Defensive patterns

Strategy: validation

Validate before calling

// issuer-side guard before signing
if groupID <= 0 {
	return fmt.Errorf("cannot sign unsubscribe token without group_id")
}

Type guard

func claimInt(claims jwt.MapClaims, key string) (int, bool) {
	f, ok := claims[key].(float64) // encoding/json numbers decode as float64
	return int(f), ok
}

Try / catch

claims, err := ParseUnsubscribeJWT(tok)
if err != nil {
	if strings.Contains(err.Error(), "group_id") {
		return nil, ErrBadTokenSchema
	}
	return err
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


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