diff --git a/pkg/files/field.go b/pkg/files/field.go
new file mode 100644
index 0000000000000000000000000000000000000000..5ce4dc4ce72506fdd62e184b95487319f95e5122
--- /dev/null
+++ b/pkg/files/field.go
@@ -0,0 +1,156 @@
+package files
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"reflect"
+
+	"git.perx.ru/perxis/perxis-go/pkg/items"
+	"git.perx.ru/perxis/perxis-go/pkg/schema/field"
+	signer "git.perx.ru/perxis/perxis-go/pkg/urlsigner"
+	"github.com/mitchellh/mapstructure"
+)
+
+const FileTypeName = "file"
+
+type FileParameters struct {
+	t *FileType
+}
+
+func (p FileParameters) Type() field.Type                   { return p.t }
+func (p *FileParameters) Clone(reset bool) field.Parameters { return p }
+
+type FileType struct {
+	fs            Files
+	signer        signer.URLSigner
+	fileServerUrl string
+	uploader      Uploader
+}
+
+func NewFileType(fs Files, signer signer.URLSigner, fileServerUrl string) *FileType {
+	return &FileType{fs: fs, signer: signer, fileServerUrl: fileServerUrl, uploader: NewUploader()}
+}
+
+func (t *FileType) WithUploader(uploader Uploader) *FileType {
+	t.uploader = uploader
+	return t
+}
+
+func (t FileType) Name() string { return FileTypeName }
+
+func (t *FileType) NewParameters() field.Parameters {
+	return &FileParameters{t}
+}
+
+func (t FileType) Decode(_ context.Context, fld *field.Field, v interface{}) (interface{}, error) {
+	if v == nil {
+		return nil, nil
+	}
+	var f File
+	if err := mapstructure.Decode(v, &f); err != nil {
+		return nil, err
+	}
+	return &f, nil
+}
+
+func (t FileType) Encode(ctx context.Context, fld *field.Field, v interface{}) (interface{}, error) {
+	if v == nil {
+		return nil, nil
+	}
+
+	f, ok := v.(*File)
+	if !ok {
+		return nil, fmt.Errorf("FileField encode error: incorrect type: \"%s\", expected \"file\"", reflect.ValueOf(v).Kind())
+	}
+
+	if f.File != nil { // upload from file system
+		upload, err := t.fs.Upload(ctx, f)
+		if err != nil {
+			return nil, err
+		}
+		if err = t.uploader.Upload(f.File, upload); err != nil {
+			return nil, err
+		}
+		f = &upload.File
+	}
+
+	u := fmt.Sprintf("%s/%s", t.fileServerUrl, f.ID)
+	resURL, err := url.Parse(u)
+	if err != nil {
+		return nil, err
+	}
+	if t.signer != nil {
+		f.URL = t.signer.Sign(resURL).String()
+	} else {
+		f.URL = resURL.String()
+	}
+	res := make(map[string]interface{})
+	if err := mapstructure.Decode(f, &res); err != nil {
+		return nil, err
+	}
+
+	return res, nil
+
+}
+
+// PreSave - функция буде вызвана перед сохранением поля в Storage. Реализует интерфейс `perxis.PreSaver`
+// Выполняет проверку поля является ли файл только что загруженным и переносит его при необходимости для
+// постоянного хранения
+func (t FileType) PreSave(ctx context.Context, fld *field.Field, v interface{}, itemCtx *items.Context) (interface{}, bool, error) {
+	if v == nil {
+		return nil, false, nil
+	}
+	f := v.(*File)
+	if f.ID == "" {
+		return nil, false, errors.New("FileType: file id required")
+	}
+	if !f.Temporary() {
+		return f, false, nil
+	}
+
+	f, err := t.fs.MoveUpload(ctx, &MultipartUpload{File: *f})
+	if err != nil {
+		return nil, false, err
+	}
+	return f, true, nil
+}
+
+// Field - создает новое поле Field типа FileType
+// FileType должен быть предварительно создан `NewFileType` и зарегистрирован `field.Register`
+func Field(o ...interface{}) *field.Field {
+	t, ok := field.GetType(FileTypeName)
+	if !ok {
+		panic("field file type not registered")
+	}
+	return field.NewField(t.NewParameters(), o...)
+}
+
+func (t *FileType) IsEmpty(v interface{}) bool {
+	if v == nil {
+		return true
+	}
+
+	f, ok := v.(*File)
+
+	return !ok || f.ID == ""
+}
+
+func (p FileParameters) GetField(path string) (fld *field.Field) {
+	switch path {
+	case "id", "name", "mimeType", "url", "key":
+		return field.String()
+	case "size":
+		return field.Number(field.NumberFormatInt)
+	default:
+		return nil
+	}
+}
+
+func init() {
+	// По умолчанию без FS
+	// Если нужны подписанные URL, и загрузка на FS, нужно зарегистрировать корректный типа
+	// РЎРј. cmd/content/command/server.go:195
+	field.Register(NewFileType(nil, nil, ""))
+}
diff --git a/pkg/urlsigner/urlsigner.go b/pkg/urlsigner/urlsigner.go
new file mode 100644
index 0000000000000000000000000000000000000000..c5535db0f3ae4f6a2deba7fbb0a1504af6aacf75
--- /dev/null
+++ b/pkg/urlsigner/urlsigner.go
@@ -0,0 +1,126 @@
+package urlsigner
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/base64"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"git.perx.ru/perxis/perxis-go/pkg/data"
+)
+
+type URLSigner interface {
+	Sign(url *url.URL) *url.URL
+	Check(url *url.URL) bool
+}
+
+const (
+	defaultSignatureExpire = 15 * time.Minute
+	defaultQueryKey        = "sign"
+
+	separator = "|"
+	saltSize  = 16
+)
+
+type urlSigner struct {
+	secret         string
+	expirationTime time.Duration
+	queryKey       string
+	params         []string
+}
+
+func NewURLSigner(secret string, expirationTime time.Duration, queryKey string, params ...string) URLSigner {
+	if len(params) == 0 {
+		params = make([]string, 0)
+	}
+	if expirationTime == 0 {
+		expirationTime = defaultSignatureExpire
+	}
+	if queryKey == "" {
+		queryKey = defaultQueryKey
+	}
+	return &urlSigner{
+		secret:         secret,
+		expirationTime: expirationTime,
+		queryKey:       queryKey,
+		params:         params,
+	}
+}
+
+func (s *urlSigner) Sign(u *url.URL) *url.URL {
+
+	q := u.Query()
+
+	h := sha256.New()
+	salt := data.GenerateRandomString(saltSize)
+	for _, p := range s.params {
+		if vv := q[p]; len(vv) > 0 {
+			for _, v := range vv {
+				h.Write([]byte(v))
+			}
+		}
+	}
+
+	h.Write([]byte(u.Path))
+	h.Write([]byte(s.expirationTime.String()))
+	h.Write([]byte(s.secret))
+	h.Write([]byte(salt))
+
+	expTime := time.Now().Add(s.expirationTime).Unix()
+	res := strings.Join([]string{strconv.FormatInt(expTime, 16), salt, string(h.Sum(nil))}, separator)
+
+	q.Set(s.queryKey, base64.URLEncoding.EncodeToString([]byte(res)))
+	u.RawQuery = q.Encode()
+	return u
+}
+
+func (s *urlSigner) Check(u *url.URL) bool {
+
+	q := u.Query()
+	sign := q.Get(s.queryKey)
+	if sign == "" {
+		return false
+	}
+
+	b, err := base64.URLEncoding.DecodeString(sign)
+	if err != nil {
+		return false
+	}
+
+	m := bytes.Split(b, []byte(separator))
+	if len(m) < 3 {
+		return false
+	}
+
+	expTime, err := strconv.ParseInt(string(m[0]), 16, 64)
+	if err != nil || time.Now().Unix() > expTime {
+		return false
+	}
+
+	salt := m[1]
+	var hash []byte
+	for i := 2; i < len(m); i++ {
+		hash = append(hash, m[i]...)
+		if len(m) > i+1 {
+			hash = append(hash, []byte(separator)...)
+		}
+	}
+
+	h := sha256.New()
+	for _, p := range s.params {
+		if vv := q[p]; len(vv) > 0 {
+			for _, v := range vv {
+				h.Write([]byte(v))
+			}
+		}
+	}
+	h.Write([]byte(u.Path))
+	h.Write([]byte(s.expirationTime.String()))
+	h.Write([]byte(s.secret))
+	h.Write(salt)
+
+	return bytes.Equal(hash, h.Sum(nil))
+}
diff --git a/pkg/urlsigner/urlsigner_test.go b/pkg/urlsigner/urlsigner_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d4a004bfcfe27cddc8d7aae99c6462669e611e7f
--- /dev/null
+++ b/pkg/urlsigner/urlsigner_test.go
@@ -0,0 +1,92 @@
+package urlsigner
+
+import (
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+const secret = "secret_key"
+
+func TestSigner(t *testing.T) {
+
+	t.Run("Not Signed", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path")
+
+		signer := NewURLSigner(secret, time.Minute, "")
+		require.False(t, signer.Check(u))
+	})
+	t.Run("Simple", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path")
+
+		signer := NewURLSigner(secret, time.Minute, "")
+		u = signer.Sign(u)
+		require.True(t, signer.Check(u))
+	})
+	t.Run("Custom Query Key", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path")
+
+		signer := NewURLSigner(secret, time.Minute, "custom")
+		u = signer.Sign(u)
+		require.True(t, signer.Check(u))
+	})
+	t.Run("URL Expired", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path")
+
+		signer := NewURLSigner(secret, time.Millisecond, "")
+		u = signer.Sign(u)
+		time.Sleep(time.Second)
+		require.False(t, signer.Check(u))
+	})
+
+	t.Run("Required Params", func(t *testing.T) {
+		t.Run("Correct", func(t *testing.T) {
+			u, _ := url.Parse("http://example.com/path?param1=1&param2=2")
+			signer := NewURLSigner(secret, time.Minute, "", "param1", "param2")
+			u = signer.Sign(u)
+			require.True(t, signer.Check(u))
+		})
+		t.Run("One Param Empty", func(t *testing.T) {
+			u, _ := url.Parse("http://example.com/path?param1=1")
+			signer := NewURLSigner(secret, time.Minute, "", "param1", "param2")
+			u = signer.Sign(u)
+			require.True(t, signer.Check(u))
+		})
+		t.Run("Exchanged Values", func(t *testing.T) {
+			u, _ := url.Parse("http://example.com/path?param1=1&param2=2")
+			signer := NewURLSigner(secret, time.Minute, "", "param1", "param2")
+			u = signer.Sign(u)
+
+			q := u.Query()
+			q.Set("param1", "2")
+			q.Set("param2", "1")
+			u.RawQuery = q.Encode()
+
+			require.False(t, signer.Check(u))
+		})
+	})
+	t.Run("Extra params", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path?param1=1")
+		signer := NewURLSigner(secret, time.Minute, "", "param1")
+		u = signer.Sign(u)
+
+		q := u.Query()
+		q.Set("extra", "100")
+		u.RawQuery = q.Encode()
+
+		require.True(t, signer.Check(u))
+	})
+	t.Run("Array Param Value", func(t *testing.T) {
+		u, _ := url.Parse("http://example.com/path?param1=1&param1=2&param1=3")
+		signer := NewURLSigner(secret, time.Minute, "", "param1")
+		u = signer.Sign(u)
+
+		q := u.Query()
+		q.Set("param1", "1")
+		u.RawQuery = q.Encode()
+
+		require.False(t, signer.Check(u))
+	})
+}