juanfont/headscale · error

group value must be an array of users

Error message

group value must be an array of users

What it means

Returned by Groups.UnmarshalJSON (hscontrol/policy/v2/types.go:1352-1354) when a value in the policy's "groups" object is not a JSON array of strings. Headscale requires every group to map to a list of usernames (e.g. "group:example": ["user@example.com"]); a bare string, number, or object is rejected at policy-parse time. The error is wrapped with the offending group key so the failing entry is identifiable.

Source

Thrown at hscontrol/policy/v2/types.go:125

var (
	ErrInvalidUsername             = errors.New("username must contain @")
	ErrUserNotFound                = errors.New("user not found")
	ErrMultipleUsersFound          = errors.New("multiple users found")
	ErrInvalidGroupFormat          = errors.New("group must start with 'group:'")
	ErrInvalidTagFormat            = errors.New("tag must start with 'tag:'")
	ErrInvalidHostname             = errors.New("invalid hostname")
	ErrHostResolve                 = errors.New("error resolving host")
	ErrInvalidPrefix               = errors.New("invalid prefix")
	ErrInvalidAutogroup            = errors.New("invalid autogroup")
	ErrUnknownAutogroup            = errors.New("unknown autogroup")
	ErrHostportMissingColon        = errors.New("hostport must contain a colon")
	ErrTypeNotSupported            = errors.New("type not supported")
	ErrInvalidAlias                = errors.New("invalid alias format")
	ErrInvalidAutoApprover         = errors.New("invalid auto approver format")
	ErrInvalidOwner                = errors.New("invalid owner format")
	ErrGroupNotDefined             = errors.New("group not defined in policy")
	ErrInvalidGroupMember          = errors.New("invalid group member type")
	ErrGroupValueNotArray          = errors.New("group value must be an array of users")
	ErrInvalidHostIP               = errors.New("hostname contains invalid IP address")
	ErrTagNotDefined               = errors.New("tag not found")
	ErrAutoApproverNotAlias        = errors.New("auto approver is not an alias")
	ErrInvalidACLAction            = errors.New("invalid ACL action")
	ErrInvalidSSHAction            = errors.New("invalid SSH action")
	ErrInvalidProtocolNumber       = errors.New("invalid protocol number")
	ErrProtocolLeadingZero         = errors.New("leading 0 not permitted in protocol number")
	ErrProtocolOutOfRange          = errors.New("protocol number out of range (0-255)")
	ErrAutogroupNotSupported       = errors.New("autogroup not supported in headscale")
	ErrAutogroupInternetSrc        = errors.New("autogroup:internet can only be used in ACL destinations")
	ErrAutogroupSelfSrc            = errors.New("\"autogroup:self\" not valid on the src side of a rule")
	ErrAutogroupNotSupportedACLSrc = errors.New("autogroup not supported for ACL sources")
	ErrAutogroupNotSupportedACLDst = errors.New("autogroup not supported for ACL destinations")
	ErrAutogroupDangerAllDst       = errors.New("cannot use autogroup:danger-all as a dst")
	ErrAutogroupNotSupportedSSHSrc = errors.New("autogroup not supported for SSH sources")
	ErrAutogroupNotSupportedSSHDst = errors.New("autogroup not supported for SSH destinations")
	ErrHostNotDefined              = errors.New("host not defined in policy")
	ErrSSHSourceAliasNotSupported  = errors.New("alias not supported for SSH source")

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Wrap the group members in square brackets: "group:admin": ["user@example.com"]
  2. Ensure every element inside the array is a plain string; nested arrays or numbers produce ErrInvalidGroupMember instead
  3. Validate the policy with `headscale policy check` (or re-run `headscale policy set`) after editing to confirm it parses

Example fix

// before (policy.hujson)
"groups": {
  "group:admin": "user@example.com"
}

// after
"groups": {
  "group:admin": ["user@example.com"]
}
Defensive patterns

Strategy: validation

Validate before calling

// Validate groups shape before loading the policy (Go caller)
func checkGroupsShape(raw map[string]json.RawMessage) error {
	for name, v := range raw["groups"].(map[string]json.RawMessage) {
		var arr []any
		if json.Unmarshal(v, &arr) != nil {
			return fmt.Errorf("group %s value must be an array", name)
		}
	}
	return nil
}

Type guard

func isGroupValueNotArray(err error) bool {
	return errors.Is(err, policy.ErrGroupValueNotArray)
}

Try / catch

err := json.Unmarshal(policyBytes, &p)
if err != nil {
	if errors.Is(err, policy.ErrGroupValueNotArray) {
		// point the user at the named group in the wrapped message
		return fmt.Errorf("fix groups section: %w", err)
	}
	return err
}

Prevention

When it happens

Trigger: A HuJSON/JSON policy containing "groups": {"group:admin": "user@example.com"} (string instead of array), or a group value that is a number/object. Surfaces when headscale loads policy from policy.path in the config, via `headscale policy set -f`, or when unmarshalling a Policy in Go code.

Common situations: Writing a group as a comma-separated string (YAML habits), copy-pasting from Tailscale ACL docs with mangled formatting, or hand-editing HuJSON and dropping the brackets. Fails at headscale startup or policy update, so the whole policy is rejected.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/2b8fbc3ec29eb8ac. Report an issue: GitHub.