diff --git a/cmd/auth/cmd.go b/cmd/auth/cmd.go
new file mode 100644
index 0000000..2f4276d
--- /dev/null
+++ b/cmd/auth/cmd.go
@@ -0,0 +1,30 @@
+package auth
+
+import (
+	"github.com/sirupsen/logrus"
+	"net"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+type CmdAuthProvider struct {
+	Cmd string
+}
+
+func (p *CmdAuthProvider) Auth(addr net.Addr, auth []byte, sSend uint64, sRecv uint64) (bool, string) {
+	cmd := exec.Command(p.Cmd, addr.String(), string(auth), strconv.Itoa(int(sSend)), strconv.Itoa(int(sRecv)))
+	out, err := cmd.Output()
+	if err != nil {
+		if _, ok := err.(*exec.ExitError); ok {
+			return false, strings.TrimSpace(string(out))
+		} else {
+			logrus.WithFields(logrus.Fields{
+				"error": err,
+			}).Error("Failed to execute auth command")
+			return false, "internal error"
+		}
+	} else {
+		return true, strings.TrimSpace(string(out))
+	}
+}
diff --git a/pkg/auth/http.go b/cmd/auth/http.go
similarity index 57%
rename from pkg/auth/http.go
rename to cmd/auth/http.go
index be55836..da4970f 100644
--- a/pkg/auth/http.go
+++ b/cmd/auth/http.go
@@ -3,6 +3,7 @@ package auth
 import (
 	"bytes"
 	"encoding/json"
+	"github.com/sirupsen/logrus"
 	"io/ioutil"
 	"net"
 	"net/http"
@@ -33,24 +34,39 @@ func (p *HTTPAuthProvider) Auth(addr net.Addr, auth []byte, sSend uint64, sRecv
 		Recv:    sRecv,
 	})
 	if err != nil {
-		return false, "Internal error"
+		logrus.WithFields(logrus.Fields{
+			"error": err,
+		}).Error("Failed to marshal auth request")
+		return false, "internal error"
 	}
 	resp, err := p.Client.Post(p.URL, "application/json", bytes.NewBuffer(jbs))
 	if err != nil {
-		return false, "Internal error"
+		logrus.WithFields(logrus.Fields{
+			"error": err,
+		}).Error("Failed to send auth request")
+		return false, "internal error"
 	}
 	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
-		return false, "Auth endpoint error"
+		logrus.WithFields(logrus.Fields{
+			"code": resp.StatusCode,
+		}).Error("Invalid status code from auth server")
+		return false, "internal error"
 	}
 	data, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
-		return false, "Auth endpoint error"
+		logrus.WithFields(logrus.Fields{
+			"error": err,
+		}).Error("Failed to read auth response")
+		return false, "internal error"
 	}
 	var ar authResp
 	err = json.Unmarshal(data, &ar)
 	if err != nil {
-		return false, "Auth endpoint error"
+		logrus.WithFields(logrus.Fields{
+			"error": err,
+		}).Error("Failed to unmarshal auth response")
+		return false, "internal error"
 	}
 	return ar.OK, ar.Msg
 }
diff --git a/cmd/main.go b/cmd/main.go
index 0d5a052..5439f8c 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -131,7 +131,7 @@ func initApp(c *cli.Context) error {
 				"version", "url",
 				"config", "file", "mode",
 				"addr", "src", "dst", "session", "action",
-				"msg", "error",
+				"code", "msg", "error",
 			},
 			TimestampFormat: c.String("log-timestamp"),
 		})
diff --git a/cmd/server.go b/cmd/server.go
index d38cb87..5d6eec6 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -2,14 +2,15 @@ package main
 
 import (
 	"crypto/tls"
+	"errors"
 	"github.com/lucas-clemente/quic-go"
 	"github.com/lucas-clemente/quic-go/congestion"
 	"github.com/oschwald/geoip2-golang"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/sirupsen/logrus"
+	"github.com/tobyxdd/hysteria/cmd/auth"
 	"github.com/tobyxdd/hysteria/pkg/acl"
-	"github.com/tobyxdd/hysteria/pkg/auth"
 	hyCongestion "github.com/tobyxdd/hysteria/pkg/congestion"
 	"github.com/tobyxdd/hysteria/pkg/core"
 	"github.com/tobyxdd/hysteria/pkg/obfs"
@@ -84,7 +85,7 @@ func server(config *serverConfig) {
 		quicConfig.MaxIncomingStreams = DefaultMaxIncomingStreams
 	}
 	// Auth
-	var authFunc func(addr net.Addr, auth []byte, sSend uint64, sRecv uint64) (bool, string)
+	var authFunc core.ConnectFunc
 	var err error
 	switch authMode := config.Auth.Mode; authMode {
 	case "", "none":
@@ -95,39 +96,24 @@ func server(config *serverConfig) {
 		authFunc = func(addr net.Addr, auth []byte, sSend uint64, sRecv uint64) (bool, string) {
 			return true, "Welcome"
 		}
-	case "password":
-		logrus.Info("Password authentication enabled")
-		var pwdConfig map[string]string
-		err = json5.Unmarshal(config.Auth.Config, &pwdConfig)
-		if err != nil || len(pwdConfig["password"]) == 0 {
+	case "password", "passwords":
+		authFunc, err = passwordAuthFunc(config.Auth.Config)
+		if err != nil {
 			logrus.WithFields(logrus.Fields{
 				"error": err,
-			}).Fatal("Invalid password authentication config")
-		}
-		pwd := pwdConfig["password"]
-		authFunc = func(addr net.Addr, auth []byte, sSend uint64, sRecv uint64) (bool, string) {
-			if string(auth) == pwd {
-				return true, "Welcome"
-			} else {
-				return false, "Wrong password"
-			}
+			}).Fatal("Failed to enable password authentication")
+		} else {
+			logrus.Info("Password authentication enabled")
 		}
 	case "external":
-		logrus.Info("External authentication enabled")
-		var extConfig map[string]string
-		err = json5.Unmarshal(config.Auth.Config, &extConfig)
-		if err != nil || len(extConfig["http"]) == 0 {
+		authFunc, err = externalAuthFunc(config.Auth.Config)
+		if err != nil {
 			logrus.WithFields(logrus.Fields{
 				"error": err,
-			}).Fatal("Invalid external authentication config")
+			}).Fatal("Failed to enable external authentication")
+		} else {
+			logrus.Info("External authentication enabled")
 		}
-		provider := &auth.HTTPAuthProvider{
-			Client: &http.Client{
-				Timeout: 10 * time.Second,
-			},
-			URL: extConfig["http"],
-		}
-		authFunc = provider.Auth
 	default:
 		logrus.WithField("mode", config.Auth.Mode).Fatal("Unsupported authentication mode")
 	}
@@ -199,6 +185,54 @@ func server(config *serverConfig) {
 	logrus.WithField("error", err).Fatal("Server shutdown")
 }
 
+func passwordAuthFunc(rawMsg json5.RawMessage) (core.ConnectFunc, error) {
+	var pwds []string
+	err := json5.Unmarshal(rawMsg, &pwds)
+	if err != nil {
+		// not a string list, legacy format?
+		var pwdConfig map[string]string
+		err = json5.Unmarshal(rawMsg, &pwdConfig)
+		if err != nil || len(pwdConfig["password"]) == 0 {
+			// still no, invalid config
+			return nil, errors.New("invalid config")
+		}
+		// yes it is
+		pwds = []string{pwdConfig["password"]}
+	}
+	return func(addr net.Addr, auth []byte, sSend uint64, sRecv uint64) (bool, string) {
+		for _, pwd := range pwds {
+			if string(auth) == pwd {
+				return true, "Welcome"
+			}
+		}
+		return false, "Wrong password"
+	}, nil
+}
+
+func externalAuthFunc(rawMsg json5.RawMessage) (core.ConnectFunc, error) {
+	var extConfig map[string]string
+	err := json5.Unmarshal(rawMsg, &extConfig)
+	if err != nil {
+		return nil, errors.New("invalid config")
+	}
+	if len(extConfig["http"]) != 0 {
+		hp := &auth.HTTPAuthProvider{
+			Client: &http.Client{
+				Timeout: 10 * time.Second,
+			},
+			URL: extConfig["http"],
+		}
+		return hp.Auth, nil
+	} else if len(extConfig["cmd"]) != 0 {
+		cp := &auth.CmdAuthProvider{
+			Cmd: extConfig["cmd"],
+		}
+		return cp.Auth, nil
+	} else {
+		return nil, errors.New("invalid config")
+	}
+}
+
 func disconnectFunc(addr net.Addr, auth []byte, err error) {
 	logrus.WithFields(logrus.Fields{
 		"src":   addr,