diff --git a/cmd/serv.go b/cmd/serv.go
index 1938388001..1b41a5a078 100644
--- a/cmd/serv.go
+++ b/cmd/serv.go
@@ -113,9 +113,12 @@ func runServ(c *cli.Context) error {
 		if err != nil {
 			fail("Internal error", "Failed to check provided key: %v", err)
 		}
-		if key.Type == models.KeyTypeDeploy {
+		switch key.Type {
+		case models.KeyTypeDeploy:
 			println("Hi there! You've successfully authenticated with the deploy key named " + key.Name + ", but Gitea does not provide shell access.")
-		} else {
+		case models.KeyTypePrincipal:
+			println("Hi there! You've successfully authenticated with the principal " + key.Content + ", but Gitea does not provide shell access.")
+		default:
 			println("Hi there, " + user.Name + "! You've successfully authenticated with the key named " + key.Name + ", but Gitea does not provide shell access.")
 		}
 		println("If this is unexpected, please log in with password and setup Gitea under another user.")
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index bc678c1934..dc273ced80 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -297,6 +297,9 @@ SSH_ROOT_PATH =
 ; Gitea will create a authorized_keys file by default when it is not using the internal ssh server
 ; If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
 SSH_CREATE_AUTHORIZED_KEYS_FILE = true
+; Gitea will create a authorized_principals file by default when it is not using the internal ssh server
+; If you intend to use the AuthorizedPrincipalsCommand functionality then you should turn this off.
+SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = true
 ; For the built-in SSH server, choose the ciphers to support for SSH connections,
 ; for system SSH this setting has no effect
 SSH_SERVER_CIPHERS = aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128
@@ -312,7 +315,26 @@ SSH_KEY_TEST_PATH =
 ; Path to ssh-keygen, default is 'ssh-keygen' which means the shell is responsible for finding out which one to call.
 SSH_KEYGEN_PATH = ssh-keygen
 ; Enable SSH Authorized Key Backup when rewriting all keys, default is true
-SSH_BACKUP_AUTHORIZED_KEYS = true
+SSH_AUTHORIZED_KEYS_BACKUP = true
+; Determines which principals to allow
+; - empty: if SSH_TRUSTED_USER_CA_KEYS is empty this will default to off, otherwise will default to email, username.
+; - off: Do not allow authorized principals
+; - email: the principal must match the user's email
+; - username: the principal must match the user's username
+; - anything: there will be no checking on the content of the principal
+SSH_AUTHORIZED_PRINCIPALS_ALLOW = email, username
+; Enable SSH Authorized Principals Backup when rewriting all keys, default is true
+SSH_AUTHORIZED_PRINCIPALS_BACKUP = true
+; Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication.
+; Multiple keys should be comma separated.
+; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
+; For more information see "TrustedUserCAKeys" in the sshd config manpages.
+SSH_TRUSTED_USER_CA_KEYS =
+; Absolute path of the `TrustedUserCaKeys` file gitea will manage.
+; Default this `RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem
+; If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your
+; sshd_config to point to this file. The official docker image will automatically work without further configuration.
+SSH_TRUSTED_USER_CA_KEYS_FILENAME =
 ; Enable exposure of SSH clone URL to anonymous visitors, default is false
 SSH_EXPOSE_ANONYMOUS = false
 ; Indicate whether to check minimum key size with corresponding type
diff --git a/docker/root/etc/templates/sshd_config b/docker/root/etc/templates/sshd_config
index 2c688ef4e0..82a9c0221e 100644
--- a/docker/root/etc/templates/sshd_config
+++ b/docker/root/etc/templates/sshd_config
@@ -13,6 +13,9 @@ HostKey /data/ssh/ssh_host_ecdsa_key
 HostKey /data/ssh/ssh_host_dsa_key
 
 AuthorizedKeysFile .ssh/authorized_keys
+AuthorizedPrincipalsFile .ssh/authorized_principals
+TrustedUserCAKeys /data/git/.ssh/gitea-trusted-user-ca-keys.pem
+CASignatureAlgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256,ssh-rsa
 
 UseDNS no
 AllowAgentForwarding no
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index 36d5af1aef..3bd667be69 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -251,6 +251,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
 - `SSH_LISTEN_PORT`: **%(SSH\_PORT)s**: Port for the built-in SSH server.
 - `SSH_ROOT_PATH`: **~/.ssh**: Root path of SSH directory. 
 - `SSH_CREATE_AUTHORIZED_KEYS_FILE`: **true**: Gitea will create a authorized_keys file by default when it is not using the internal ssh server. If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
+- `SSH_TRUSTED_USER_CA_KEYS`: **\<empty\>**: Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication. Multiple keys should be comma separated. E.g.`ssh-<algorithm> <key>` or `ssh-<algorithm> <key1>, ssh-<algorithm> <key2>`. For more information see `TrustedUserCAKeys` in the sshd config man pages. When empty no file will be created and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` will default to `off`.
+- `SSH_TRUSTED_USER_CA_KEYS_FILENAME`: **`RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem**: Absolute path of the `TrustedUserCaKeys` file gitea will manage. If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your sshd_config to point to this file. The official docker image will automatically work without further configuration.
+- `SSH_AUTHORIZED_PRINCIPALS_ALLOW`: **off** or **username, email**: \[off, username, email, anything\]: Specify the principals values that users are allowed to use as principal. When set to `anything` no checks are done on the principal string. When set to `off` authorized principal are not allowed to be set.
+- `SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE`: **false/true**: Gitea will create a authorized_principals file by default when it is not using the internal ssh server and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
+- `SSH_AUTHORIZED_PRINCIPALS_BACKUP`: **false/true**: Enable SSH Authorized Principals Backup when rewriting all keys, default is true if `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
 - `SSH_SERVER_CIPHERS`: **aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128**: For the built-in SSH server, choose the ciphers to support for SSH connections, for system SSH this setting has no effect.
 - `SSH_SERVER_KEY_EXCHANGES`: **diffie-hellman-group1-sha1, diffie-hellman-group14-sha1, ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521, curve25519-sha256@libssh.org**: For the built-in SSH server, choose the key exchange algorithms to support for SSH connections, for system SSH this setting has no effect.
 - `SSH_SERVER_MACS`: **hmac-sha2-256-etm@openssh.com, hmac-sha2-256, hmac-sha1, hmac-sha1-96**: For the built-in SSH server, choose the MACs to support for SSH connections, for system SSH this setting has no effect
diff --git a/models/ssh_key.go b/models/ssh_key.go
index b46ff76b94..d67981398b 100644
--- a/models/ssh_key.go
+++ b/models/ssh_key.go
@@ -40,6 +40,8 @@ const (
 	tplCommentPrefix = `# gitea public key`
 	tplCommand       = "%s --config=%s serv key-%d"
 	tplPublicKey     = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n"
+
+	authorizedPrincipalsFile = "authorized_principals"
 )
 
 var sshOpLocker sync.Mutex
@@ -52,6 +54,8 @@ const (
 	KeyTypeUser = iota + 1
 	// KeyTypeDeploy specifies the deploy key
 	KeyTypeDeploy
+	// KeyTypePrincipal specifies the authorized principal key
+	KeyTypePrincipal
 )
 
 // PublicKey represents a user or deploy SSH public key.
@@ -401,6 +405,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
 	}
 
 	for _, key := range keys {
+		if key.Type == KeyTypePrincipal {
+			continue
+		}
 		if _, err = f.WriteString(key.AuthorizedString()); err != nil {
 			return err
 		}
@@ -571,6 +578,25 @@ func SearchPublicKeyByContent(content string) (*PublicKey, error) {
 	return searchPublicKeyByContentWithEngine(x, content)
 }
 
+func searchPublicKeyByContentExactWithEngine(e Engine, content string) (*PublicKey, error) {
+	key := new(PublicKey)
+	has, err := e.
+		Where("content = ?", content).
+		Get(key)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrKeyNotExist{}
+	}
+	return key, nil
+}
+
+// SearchPublicKeyByContentExact searches content
+// and returns public key found.
+func SearchPublicKeyByContentExact(content string) (*PublicKey, error) {
+	return searchPublicKeyByContentExactWithEngine(x, content)
+}
+
 // SearchPublicKey returns a list of public keys matching the provided arguments.
 func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
 	keys := make([]*PublicKey, 0, 5)
@@ -586,7 +612,7 @@ func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
 
 // ListPublicKeys returns a list of public keys belongs to given user.
 func ListPublicKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
-	sess := x.Where("owner_id = ?", uid)
+	sess := x.Where("owner_id = ? AND type != ?", uid, KeyTypePrincipal)
 	if listOptions.Page != 0 {
 		sess = listOptions.setSessionPagination(sess)
 
@@ -662,6 +688,10 @@ func DeletePublicKey(doer *User, id int64) (err error) {
 	}
 	sess.Close()
 
+	if key.Type == KeyTypePrincipal {
+		return RewriteAllPrincipalKeys()
+	}
+
 	return RewriteAllPublicKeys()
 }
 
@@ -727,11 +757,10 @@ func RegeneratePublicKeys(t io.StringWriter) error {
 }
 
 func regeneratePublicKeys(e Engine, t io.StringWriter) error {
-	err := e.Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
+	if err := e.Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
 		_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
 		return err
-	})
-	if err != nil {
+	}); err != nil {
 		return err
 	}
 
@@ -1041,3 +1070,204 @@ func SearchDeployKeys(repoID int64, keyID int64, fingerprint string) ([]*DeployK
 	}
 	return keys, x.Where(cond).Find(&keys)
 }
+
+// __________       .__              .__             .__
+// \______   _______|__| ____   ____ |_____________  |  |   ______
+//  |     ___\_  __ |  |/    \_/ ___\|  \____ \__  \ |  |  /  ___/
+//  |    |    |  | \|  |   |  \  \___|  |  |_> / __ \|  |__\___ \
+//  |____|    |__|  |__|___|  /\___  |__|   __(____  |____/____  >
+//                          \/     \/   |__|       \/          \/
+
+// AddPrincipalKey adds new principal to database and authorized_principals file.
+func AddPrincipalKey(ownerID int64, content string, loginSourceID int64) (*PublicKey, error) {
+	sess := x.NewSession()
+	defer sess.Close()
+	if err := sess.Begin(); err != nil {
+		return nil, err
+	}
+
+	// Principals cannot be duplicated.
+	has, err := sess.
+		Where("content = ? AND type = ?", content, KeyTypePrincipal).
+		Get(new(PublicKey))
+	if err != nil {
+		return nil, err
+	} else if has {
+		return nil, ErrKeyAlreadyExist{0, "", content}
+	}
+
+	key := &PublicKey{
+		OwnerID:       ownerID,
+		Name:          content,
+		Content:       content,
+		Mode:          AccessModeWrite,
+		Type:          KeyTypePrincipal,
+		LoginSourceID: loginSourceID,
+	}
+	if err = addPrincipalKey(sess, key); err != nil {
+		return nil, fmt.Errorf("addKey: %v", err)
+	}
+
+	if err = sess.Commit(); err != nil {
+		return nil, err
+	}
+
+	sess.Close()
+
+	return key, RewriteAllPrincipalKeys()
+}
+
+func addPrincipalKey(e Engine, key *PublicKey) (err error) {
+	// Save Key representing a principal.
+	if _, err = e.Insert(key); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines
+func CheckPrincipalKeyString(user *User, content string) (_ string, err error) {
+	if setting.SSH.Disabled {
+		return "", ErrSSHDisabled{}
+	}
+
+	content = strings.TrimSpace(content)
+	if strings.ContainsAny(content, "\r\n") {
+		return "", errors.New("only a single line with a single principal please")
+	}
+
+	// check all the allowed principals, email, username or anything
+	// if any matches, return ok
+	for _, v := range setting.SSH.AuthorizedPrincipalsAllow {
+		switch v {
+		case "anything":
+			return content, nil
+		case "email":
+			emails, err := GetEmailAddresses(user.ID)
+			if err != nil {
+				return "", err
+			}
+			for _, email := range emails {
+				if !email.IsActivated {
+					continue
+				}
+				if content == email.Email {
+					return content, nil
+				}
+			}
+
+		case "username":
+			if content == user.Name {
+				return content, nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("didn't match allowed principals: %s", setting.SSH.AuthorizedPrincipalsAllow)
+}
+
+// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
+// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function
+// outside any session scope independently.
+func RewriteAllPrincipalKeys() error {
+	return rewriteAllPrincipalKeys(x)
+}
+
+func rewriteAllPrincipalKeys(e Engine) error {
+	// Don't rewrite key if internal server
+	if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile {
+		return nil
+	}
+
+	sshOpLocker.Lock()
+	defer sshOpLocker.Unlock()
+
+	if setting.SSH.RootPath != "" {
+		// First of ensure that the RootPath is present, and if not make it with 0700 permissions
+		// This of course doesn't guarantee that this is the right directory for authorized_keys
+		// but at least if it's supposed to be this directory and it doesn't exist and we're the
+		// right user it will at least be created properly.
+		err := os.MkdirAll(setting.SSH.RootPath, 0700)
+		if err != nil {
+			log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
+			return err
+		}
+	}
+
+	fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
+	tmpPath := fPath + ".tmp"
+	t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		t.Close()
+		os.Remove(tmpPath)
+	}()
+
+	if setting.SSH.AuthorizedPrincipalsBackup && com.IsExist(fPath) {
+		bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
+		if err = com.Copy(fPath, bakPath); err != nil {
+			return err
+		}
+	}
+
+	if err := regeneratePrincipalKeys(e, t); err != nil {
+		return err
+	}
+
+	t.Close()
+	return os.Rename(tmpPath, fPath)
+}
+
+// ListPrincipalKeys returns a list of principals belongs to given user.
+func ListPrincipalKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
+	sess := x.Where("owner_id = ? AND type = ?", uid, KeyTypePrincipal)
+	if listOptions.Page != 0 {
+		sess = listOptions.setSessionPagination(sess)
+
+		keys := make([]*PublicKey, 0, listOptions.PageSize)
+		return keys, sess.Find(&keys)
+	}
+
+	keys := make([]*PublicKey, 0, 5)
+	return keys, sess.Find(&keys)
+}
+
+// RegeneratePrincipalKeys regenerates the authorized_principals file
+func RegeneratePrincipalKeys(t io.StringWriter) error {
+	return regeneratePrincipalKeys(x, t)
+}
+
+func regeneratePrincipalKeys(e Engine, t io.StringWriter) error {
+	if err := e.Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
+		_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
+		return err
+	}); err != nil {
+		return err
+	}
+
+	fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
+	if com.IsExist(fPath) {
+		f, err := os.Open(fPath)
+		if err != nil {
+			return err
+		}
+		scanner := bufio.NewScanner(f)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if strings.HasPrefix(line, tplCommentPrefix) {
+				scanner.Scan()
+				continue
+			}
+			_, err = t.WriteString(line + "\n")
+			if err != nil {
+				f.Close()
+				return err
+			}
+		}
+		f.Close()
+	}
+	return nil
+}
diff --git a/models/user.go b/models/user.go
index 63ce6ffdfc..6c57dd473a 100644
--- a/models/user.go
+++ b/models/user.go
@@ -1254,6 +1254,10 @@ func deleteUser(e *xorm.Session, u *User) error {
 	if err != nil {
 		return err
 	}
+	err = rewriteAllPrincipalKeys(e)
+	if err != nil {
+		return err
+	}
 	// ***** END: PublicKey *****
 
 	// ***** START: GPGPublicKey *****
diff --git a/modules/cron/tasks_extended.go b/modules/cron/tasks_extended.go
index fa2d6e0c38..f0742eb471 100644
--- a/modules/cron/tasks_extended.go
+++ b/modules/cron/tasks_extended.go
@@ -67,6 +67,16 @@ func registerRewriteAllPublicKeys() {
 	})
 }
 
+func registerRewriteAllPrincipalKeys() {
+	RegisterTaskFatal("resync_all_sshprincipals", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(_ context.Context, _ *models.User, _ Config) error {
+		return models.RewriteAllPrincipalKeys()
+	})
+}
+
 func registerRepositoryUpdateHook() {
 	RegisterTaskFatal("resync_all_hooks", &BaseConfig{
 		Enabled:    false,
@@ -112,6 +122,7 @@ func initExtendedTasks() {
 	registerDeleteRepositoryArchives()
 	registerGarbageCollectRepositories()
 	registerRewriteAllPublicKeys()
+	registerRewriteAllPrincipalKeys()
 	registerRepositoryUpdateHook()
 	registerReinitMissingRepositories()
 	registerDeleteMissingRepositories()
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 52a14e0d28..8088cffcdf 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -28,6 +28,7 @@ import (
 
 	shellquote "github.com/kballard/go-shellquote"
 	"github.com/unknwon/com"
+	gossh "golang.org/x/crypto/ssh"
 	ini "gopkg.in/ini.v1"
 	"strk.kbt.io/projects/go/libravatar"
 )
@@ -103,24 +104,31 @@ var (
 	StaticURLPrefix      string
 
 	SSH = struct {
-		Disabled                 bool           `ini:"DISABLE_SSH"`
-		StartBuiltinServer       bool           `ini:"START_SSH_SERVER"`
-		BuiltinServerUser        string         `ini:"BUILTIN_SSH_SERVER_USER"`
-		Domain                   string         `ini:"SSH_DOMAIN"`
-		Port                     int            `ini:"SSH_PORT"`
-		ListenHost               string         `ini:"SSH_LISTEN_HOST"`
-		ListenPort               int            `ini:"SSH_LISTEN_PORT"`
-		RootPath                 string         `ini:"SSH_ROOT_PATH"`
-		ServerCiphers            []string       `ini:"SSH_SERVER_CIPHERS"`
-		ServerKeyExchanges       []string       `ini:"SSH_SERVER_KEY_EXCHANGES"`
-		ServerMACs               []string       `ini:"SSH_SERVER_MACS"`
-		KeyTestPath              string         `ini:"SSH_KEY_TEST_PATH"`
-		KeygenPath               string         `ini:"SSH_KEYGEN_PATH"`
-		AuthorizedKeysBackup     bool           `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
-		MinimumKeySizeCheck      bool           `ini:"-"`
-		MinimumKeySizes          map[string]int `ini:"-"`
-		CreateAuthorizedKeysFile bool           `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
-		ExposeAnonymous          bool           `ini:"SSH_EXPOSE_ANONYMOUS"`
+		Disabled                       bool              `ini:"DISABLE_SSH"`
+		StartBuiltinServer             bool              `ini:"START_SSH_SERVER"`
+		BuiltinServerUser              string            `ini:"BUILTIN_SSH_SERVER_USER"`
+		Domain                         string            `ini:"SSH_DOMAIN"`
+		Port                           int               `ini:"SSH_PORT"`
+		ListenHost                     string            `ini:"SSH_LISTEN_HOST"`
+		ListenPort                     int               `ini:"SSH_LISTEN_PORT"`
+		RootPath                       string            `ini:"SSH_ROOT_PATH"`
+		ServerCiphers                  []string          `ini:"SSH_SERVER_CIPHERS"`
+		ServerKeyExchanges             []string          `ini:"SSH_SERVER_KEY_EXCHANGES"`
+		ServerMACs                     []string          `ini:"SSH_SERVER_MACS"`
+		KeyTestPath                    string            `ini:"SSH_KEY_TEST_PATH"`
+		KeygenPath                     string            `ini:"SSH_KEYGEN_PATH"`
+		AuthorizedKeysBackup           bool              `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
+		AuthorizedPrincipalsBackup     bool              `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"`
+		MinimumKeySizeCheck            bool              `ini:"-"`
+		MinimumKeySizes                map[string]int    `ini:"-"`
+		CreateAuthorizedKeysFile       bool              `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
+		CreateAuthorizedPrincipalsFile bool              `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"`
+		ExposeAnonymous                bool              `ini:"SSH_EXPOSE_ANONYMOUS"`
+		AuthorizedPrincipalsAllow      []string          `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"`
+		AuthorizedPrincipalsEnabled    bool              `ini:"-"`
+		TrustedUserCAKeys              []string          `ini:"SSH_TRUSTED_USER_CA_KEYS"`
+		TrustedUserCAKeysFile          string            `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"`
+		TrustedUserCAKeysParsed        []gossh.PublicKey `ini:"-"`
 	}{
 		Disabled:            false,
 		StartBuiltinServer:  false,
@@ -672,12 +680,38 @@ func NewContext() {
 		SSH.StartBuiltinServer = false
 	}
 
+	trustedUserCaKeys := sec.Key("SSH_TRUSTED_USER_CA_KEYS").Strings(",")
+	for _, caKey := range trustedUserCaKeys {
+		pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey))
+		if err != nil {
+			log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err)
+		}
+
+		SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey)
+	}
+	if len(trustedUserCaKeys) > 0 {
+		// Set the default as email,username otherwise we can leave it empty
+		sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email")
+	} else {
+		sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off")
+	}
+
+	SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(","))
+
 	if !SSH.Disabled && !SSH.StartBuiltinServer {
 		if err := os.MkdirAll(SSH.RootPath, 0700); err != nil {
 			log.Fatal("Failed to create '%s': %v", SSH.RootPath, err)
 		} else if err = os.MkdirAll(SSH.KeyTestPath, 0644); err != nil {
 			log.Fatal("Failed to create '%s': %v", SSH.KeyTestPath, err)
 		}
+
+		if len(trustedUserCaKeys) > 0 && SSH.AuthorizedPrincipalsEnabled {
+			fname := sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem"))
+			if err := ioutil.WriteFile(fname,
+				[]byte(strings.Join(trustedUserCaKeys, "\n")), 0600); err != nil {
+				log.Fatal("Failed to create '%s': %v", fname, err)
+			}
+		}
 	}
 
 	SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck)
@@ -689,8 +723,17 @@ func NewContext() {
 			delete(SSH.MinimumKeySizes, strings.ToLower(key.Name()))
 		}
 	}
+
 	SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(true)
 	SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true)
+
+	SSH.AuthorizedPrincipalsBackup = false
+	SSH.CreateAuthorizedPrincipalsFile = false
+	if SSH.AuthorizedPrincipalsEnabled {
+		SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true)
+		SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true)
+	}
+
 	SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false)
 
 	if err = Cfg.Section("oauth2").MapTo(&OAuth2); err != nil {
@@ -944,6 +987,38 @@ func NewContext() {
 	}
 }
 
+func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) {
+	anything := false
+	email := false
+	username := false
+	for _, value := range values {
+		v := strings.ToLower(strings.TrimSpace(value))
+		switch v {
+		case "off":
+			return []string{"off"}, false
+		case "email":
+			email = true
+		case "username":
+			username = true
+		case "anything":
+			anything = true
+		}
+	}
+	if anything {
+		return []string{"anything"}, true
+	}
+
+	authorizedPrincipalsAllow := []string{}
+	if username {
+		authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username")
+	}
+	if email {
+		authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email")
+	}
+
+	return authorizedPrincipalsAllow, true
+}
+
 func loadInternalToken(sec *ini.Section) string {
 	uri := sec.Key("INTERNAL_TOKEN_URI").String()
 	if len(uri) == 0 {
diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go
index e7a694683a..7a449dd41b 100644
--- a/modules/ssh/ssh.go
+++ b/modules/ssh/ssh.go
@@ -5,6 +5,7 @@
 package ssh
 
 import (
+	"bytes"
 	"crypto/rand"
 	"crypto/rsa"
 	"crypto/x509"
@@ -136,6 +137,52 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 		return false
 	}
 
+	// check if we have a certificate
+	if cert, ok := key.(*gossh.Certificate); ok {
+		if len(setting.SSH.TrustedUserCAKeys) == 0 {
+			return false
+		}
+
+		// look for the exact principal
+		for _, principal := range cert.ValidPrincipals {
+			pkey, err := models.SearchPublicKeyByContentExact(principal)
+			if err != nil {
+				log.Error("SearchPublicKeyByContentExact: %v", err)
+				return false
+			}
+
+			if models.IsErrKeyNotExist(err) {
+				continue
+			}
+
+			c := &gossh.CertChecker{
+				IsUserAuthority: func(auth gossh.PublicKey) bool {
+					for _, k := range setting.SSH.TrustedUserCAKeysParsed {
+						if bytes.Equal(auth.Marshal(), k.Marshal()) {
+							return true
+						}
+					}
+
+					return false
+				},
+			}
+
+			// check the CA of the cert
+			if !c.IsUserAuthority(cert.SignatureKey) {
+				return false
+			}
+
+			// validate the cert for this principal
+			if err := c.CheckCert(principal, cert); err != nil {
+				return false
+			}
+
+			ctx.SetValue(giteaKeyID, pkey.ID)
+
+			return true
+		}
+	}
+
 	pkey, err := models.SearchPublicKeyByContent(strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
 	if err != nil {
 		log.Error("SearchPublicKeyByContent: %v", err)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9acc9b8bf6..45feaf8c04 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -383,6 +383,7 @@ cannot_add_org_to_team = An organization cannot be added as a team member.
 
 invalid_ssh_key = Can not verify your SSH key: %s
 invalid_gpg_key = Can not verify your GPG key: %s
+invalid_ssh_principal = Invalid principal: %s
 unable_verify_ssh_key = "Can not verify the SSH key; double-check it for mistakes."
 auth_failed = Authentication failed: %v
 
@@ -501,9 +502,11 @@ keep_email_private_popup = Your email address will be hidden from other users.
 openid_desc = OpenID lets you delegate authentication to an external provider.
 
 manage_ssh_keys = Manage SSH Keys
+manage_ssh_principals = Manage SSH Certificate Principals
 manage_gpg_keys = Manage GPG Keys
 add_key = Add Key
 ssh_desc = These public SSH keys are associated with your account. The corresponding private keys allow full access to your repositories.
+principal_desc = These SSH certificate principals are associated with your account and allow full access to your repositories.
 gpg_desc = These public GPG keys are associated with your account. Keep your private keys safe as they allow commits to be verified.
 ssh_helper = <strong>Need help?</strong> Have a look at GitHub's guide to <a href="%s">create your own SSH keys</a> or solve <a href="%s">common problems</a> you may encounter using SSH.
 gpg_helper = <strong>Need help?</strong> Have a look at GitHub's guide <a href="%s">about GPG</a>.
@@ -511,23 +514,30 @@ add_new_key = Add SSH Key
 add_new_gpg_key = Add GPG Key
 key_content_ssh_placeholder = Begins with 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', or 'ecdsa-sha2-nistp521'
 key_content_gpg_placeholder = Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'
+add_new_principal = Add Principal
 ssh_key_been_used = This SSH key has already been added to the server.
-ssh_key_name_used = An SSH key with same name is already added to your account.
+ssh_key_name_used = An SSH key with same name already exists on your account.
+ssh_principal_been_used = This principal has already been added to the server.
 gpg_key_id_used = A public GPG key with same ID already exists.
 gpg_no_key_email_found = This GPG key is not usable with any email address associated with your account.
 subkeys = Subkeys
 key_id = Key ID
 key_name = Key Name
 key_content = Content
+principal_content = Content
 add_key_success = The SSH key '%s' has been added.
 add_gpg_key_success = The GPG key '%s' has been added.
+add_principal_success = The SSH certificate principal '%s' has been added.
 delete_key = Remove
 ssh_key_deletion = Remove SSH Key
 gpg_key_deletion = Remove GPG Key
+ssh_principal_deletion = Remove SSH Certificate Principal
 ssh_key_deletion_desc = Removing an SSH key revokes its access to your account. Continue?
 gpg_key_deletion_desc = Removing a GPG key un-verifies commits signed by it. Continue?
+ssh_principal_deletion_desc = Removing a SSH Certificate Principal revokes its access to your account. Continue?
 ssh_key_deletion_success = The SSH key has been removed.
 gpg_key_deletion_success = The GPG key has been removed.
+ssh_principal_deletion_success = The principal has been removed.
 add_on = Added on
 valid_until = Valid until
 valid_forever = Valid forever
@@ -537,10 +547,10 @@ can_read_info = Read
 can_write_info = Write
 key_state_desc = This key has been used in the last 7 days
 token_state_desc = This token has been used in the last 7 days
+principal_state_desc = This principal has been used in the last 7 days
 show_openid = Show on profile
 hide_openid = Hide from profile
 ssh_disabled = SSH Disabled
-
 manage_social = Manage Associated Social Accounts
 social_desc = These social accounts are linked to your Gitea account. Make sure you recognize all of them as they can be used to sign in to your Gitea account.
 unbind = Unlink
@@ -1994,6 +2004,8 @@ dashboard.update_migration_poster_id = Update migration poster IDs
 dashboard.git_gc_repos = Garbage collect all repositories
 dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys.
 dashboard.resync_all_sshkeys.desc = (Not needed for the built-in SSH server.)
+dashboard.resync_all_sshprincipals = Update the '.ssh/authorized_principals' file with Gitea SSH principals.
+dashboard.resync_all_sshprincipals.desc = (Not needed for the built-in SSH server.)
 dashboard.resync_all_hooks = Resynchronize pre-receive, update and post-receive hooks of all repositories.
 dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for which records exist
 dashboard.sync_external_users = Synchronize external user data
diff --git a/routers/private/serv.go b/routers/private/serv.go
index f463ff6828..79683c2826 100644
--- a/routers/private/serv.go
+++ b/routers/private/serv.go
@@ -46,7 +46,7 @@ func ServNoCommand(ctx *macaron.Context) {
 	}
 	results.Key = key
 
-	if key.Type == models.KeyTypeUser {
+	if key.Type == models.KeyTypeUser || key.Type == models.KeyTypePrincipal {
 		user, err := models.GetUserByID(key.OwnerID)
 		if err != nil {
 			if models.IsErrUserNotExist(err) {
diff --git a/routers/user/setting/keys.go b/routers/user/setting/keys.go
index a7978fe14e..6a39666e94 100644
--- a/routers/user/setting/keys.go
+++ b/routers/user/setting/keys.go
@@ -22,6 +22,8 @@ func Keys(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("settings")
 	ctx.Data["PageIsSettingsKeys"] = true
 	ctx.Data["DisableSSH"] = setting.SSH.Disabled
+	ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+	ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
 
 	loadKeysData(ctx)
 
@@ -32,6 +34,9 @@ func Keys(ctx *context.Context) {
 func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
 	ctx.Data["Title"] = ctx.Tr("settings")
 	ctx.Data["PageIsSettingsKeys"] = true
+	ctx.Data["DisableSSH"] = setting.SSH.Disabled
+	ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+	ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
 
 	if ctx.HasError() {
 		loadKeysData(ctx)
@@ -40,6 +45,32 @@ func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
 		return
 	}
 	switch form.Type {
+	case "principal":
+		content, err := models.CheckPrincipalKeyString(ctx.User, form.Content)
+		if err != nil {
+			if models.IsErrSSHDisabled(err) {
+				ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+			} else {
+				ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error()))
+			}
+			ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+			return
+		}
+		if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil {
+			ctx.Data["HasPrincipalError"] = true
+			switch {
+			case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err):
+				loadKeysData(ctx)
+
+				ctx.Data["Err_Content"] = true
+				ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form)
+			default:
+				ctx.ServerError("AddPrincipalKey", err)
+			}
+			return
+		}
+		ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
+		ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
 	case "gpg":
 		keys, err := models.AddGPGKey(ctx.User.ID, form.Content)
 		if err != nil {
@@ -134,6 +165,12 @@ func DeleteKey(ctx *context.Context) {
 		} else {
 			ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
 		}
+	case "principal":
+		if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil {
+			ctx.Flash.Error("DeletePublicKey: " + err.Error())
+		} else {
+			ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
+		}
 	default:
 		ctx.Flash.Warning("Function not implemented")
 		ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
@@ -157,4 +194,11 @@ func loadKeysData(ctx *context.Context) {
 		return
 	}
 	ctx.Data["GPGKeys"] = gpgkeys
+
+	principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{})
+	if err != nil {
+		ctx.ServerError("ListPrincipalKeys", err)
+		return
+	}
+	ctx.Data["Principals"] = principals
 }
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index d52f82fb63..911cdb9fef 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -42,6 +42,11 @@
 							<td><button type="submit" class="ui green button" name="op" value="resync_all_sshkeys">{{svg "octicon-play"}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						{{end}}
+						<tr>
+							<td>{{.i18n.Tr "admin.dashboard.resync_all_sshprincipals"}}<br/>
+							{{.i18n.Tr "admin.dashboard.resync_all_sshprincipals.desc"}}</td>
+							<td><button type="submit" class="ui green button" name="op" value="resync_all_sshprincipals">{{svg "octicon-play" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.resync_all_hooks"}}</td>
 							<td><button type="submit" class="ui green button" name="op" value="resync_all_hooks">{{svg "octicon-play"}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
diff --git a/templates/user/settings/keys.tmpl b/templates/user/settings/keys.tmpl
index 0a1d380f6c..3653761ac5 100644
--- a/templates/user/settings/keys.tmpl
+++ b/templates/user/settings/keys.tmpl
@@ -4,6 +4,7 @@
 	<div class="ui container">
 		{{template "base/alert" .}}
 		{{template "user/settings/keys_ssh" .}}
+		{{template "user/settings/keys_principal" .}}
 		{{template "user/settings/keys_gpg" .}}
 	</div>
 </div>
diff --git a/templates/user/settings/keys_principal.tmpl b/templates/user/settings/keys_principal.tmpl
new file mode 100644
index 0000000000..c163263ea9
--- /dev/null
+++ b/templates/user/settings/keys_principal.tmpl
@@ -0,0 +1,67 @@
+{{if .AllowPrincipals}}
+	<h4 class="ui top attached header">
+		{{.i18n.Tr "settings.manage_ssh_principals"}}
+		<div class="ui right">
+		{{if not .DisableSSH}}
+			<div class="ui blue tiny show-panel button" data-panel="#add-ssh-principal-panel">{{.i18n.Tr "settings.add_new_principal"}}</div>
+		{{else}}
+			<div class="ui blue tiny button disabled">{{.i18n.Tr "settings.ssh_disabled"}}</div>
+		{{end}}
+		</div>
+	</h4>
+	<div class="ui attached segment">
+		<div class="ui key list">
+			<div class="item">
+				{{.i18n.Tr "settings.principal_desc"}}
+			</div>
+			{{range .Principals}}
+				<div class="item">
+					<div class="right floated content">
+						<button class="ui red tiny button delete-button" id="delete-principal" data-url="{{$.Link}}/delete?type=principal" data-id="{{.ID}}">
+							{{$.i18n.Tr "settings.delete_key"}}
+						</button>
+					</div>
+					<i class="big send icon {{if .HasRecentActivity}}green{{end}}" {{if .HasRecentActivity}}data-content="{{$.i18n.Tr "settings.principal_state_desc"}}" data-variation="inverted tiny"{{end}}></i>
+					<div class="content">
+						<strong>{{.Name}}</strong>
+						<div class="activity meta">
+							<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> —  {{svg "octicon-info" 16}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
+						</div>
+					</div>
+				</div>
+			{{end}}
+		</div>
+	</div>
+	<br>
+
+	<div {{if not .HasPrincipalError}}class="hide"{{end}} id="add-ssh-principal-panel">
+		<h4 class="ui top attached header">
+			{{.i18n.Tr "settings.add_new_principal"}}
+		</h4>
+		<div class="ui attached segment">
+			<form class="ui form" action="{{.Link}}" method="post">
+				{{.CsrfTokenHtml}}
+				<div class="field {{if .Err_Content}}error{{end}}">
+					<label for="content">{{.i18n.Tr "settings.principal_content"}}</label>
+					<input id="ssh-principal-content" name="content" value="{{.content}}" autofocus required>
+				</div>
+				<input name="title" type="hidden" value="principal">
+				<input name="type" type="hidden" value="principal">
+				<button class="ui green button">
+					{{.i18n.Tr "settings.add_new_principal"}}
+				</button>
+			</form>
+		</div>
+	</div>
+
+	<div class="ui small basic delete modal" id="delete-principal">
+		<div class="ui icon header">
+			<i class="trash icon"></i>
+			{{.i18n.Tr "settings.ssh_principal_deletion"}}
+		</div>
+		<div class="content">
+			<p>{{.i18n.Tr "settings.ssh_principal_deletion_desc"}}</p>
+		</div>
+		{{template "base/delete_modal_actions" .}}
+	</div>
+{{end}}