diff --git a/cmd/dex/config.go b/cmd/dex/config.go
index 9db0ce3ff065f9e2eb2a785f88dcfc35bb21e219..3d07f2ff62bed10098176a307bc1f4539dbf28f2 100644
--- a/cmd/dex/config.go
+++ b/cmd/dex/config.go
@@ -133,6 +133,8 @@ type OAuth2 struct {
 	SkipApprovalScreen bool `json:"skipApprovalScreen"`
 	// If specified, show the connector selection screen even if there's only one
 	AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
+	// This is the connector that can be used for password grant
+	PasswordConnector string `json:"passwordConnector"`
 }
 
 // Web is the config format for the HTTP server.
diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go
index 293d3e66d93b48977338a442976af20d3915b615..86f02c78c8924d55013a174df1b4b21222698e51 100644
--- a/cmd/dex/serve.go
+++ b/cmd/dex/serve.go
@@ -201,6 +201,9 @@ func serve(cmd *cobra.Command, args []string) error {
 	if c.OAuth2.SkipApprovalScreen {
 		logger.Infof("config skipping approval screen")
 	}
+	if c.OAuth2.PasswordConnector != "" {
+		logger.Infof("config using password grant connector: %s", c.OAuth2.PasswordConnector)
+	}
 	if len(c.Web.AllowedOrigins) > 0 {
 		logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins)
 	}
@@ -212,6 +215,7 @@ func serve(cmd *cobra.Command, args []string) error {
 		SupportedResponseTypes: c.OAuth2.ResponseTypes,
 		SkipApprovalScreen:     c.OAuth2.SkipApprovalScreen,
 		AlwaysShowLoginScreen:  c.OAuth2.AlwaysShowLoginScreen,
+		PasswordConnector:      c.OAuth2.PasswordConnector,
 		AllowedOrigins:         c.Web.AllowedOrigins,
 		Issuer:                 c.Issuer,
 		Storage:                s,
diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml
index 111a0224987f8dc0e4288ae1e8757e9ebbe05e3c..099624f89bf26a1d0071eaa37336de5e719732fe 100644
--- a/examples/config-dev.yaml
+++ b/examples/config-dev.yaml
@@ -53,6 +53,8 @@ telemetry:
     # go directly to it. For connected IdPs, this redirects the browser away
     # from application to upstream provider such as the Google login page
 #   alwaysShowLoginScreen: false
+    # Uncommend the passwordConnector to use a specific connector for password grants
+#   passwordConnector: local
 
 # Instead of reading from an external storage, use this list of clients.
 #
diff --git a/server/handlers.go b/server/handlers.go
index 24d39999736e34c12380a4da7b93e99ab9ed20c1..694ababb36a2cfccd57d7e9b3c0be2f63aa5f3df 100644
--- a/server/handlers.go
+++ b/server/handlers.go
@@ -756,6 +756,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
 		s.handleAuthCode(w, r, client)
 	case grantTypeRefreshToken:
 		s.handleRefreshToken(w, r, client)
+	case grantTypePassword:
+		s.handlePasswordGrant(w, r, client)
 	default:
 		s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
 	}
@@ -1150,6 +1152,221 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
 	w.Write(claims)
 }
 
+func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) {
+	// Parse the fields
+	if err := r.ParseForm(); err != nil {
+		s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest)
+		return
+	}
+	q := r.Form
+
+	nonce := q.Get("nonce")
+	// Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
+	scopes := strings.Fields(q.Get("scope"))
+
+	// Parse the scopes if they are passed
+	var (
+		unrecognized  []string
+		invalidScopes []string
+	)
+	hasOpenIDScope := false
+	for _, scope := range scopes {
+		switch scope {
+		case scopeOpenID:
+			hasOpenIDScope = true
+		case scopeOfflineAccess, scopeEmail, scopeProfile, scopeGroups, scopeFederatedID:
+		default:
+			peerID, ok := parseCrossClientScope(scope)
+			if !ok {
+				unrecognized = append(unrecognized, scope)
+				continue
+			}
+
+			isTrusted, err := s.validateCrossClientTrust(client.ID, peerID)
+			if err != nil {
+				s.tokenErrHelper(w, errInvalidClient, fmt.Sprintf("Error validating cross client trust %v.", err), http.StatusBadRequest)
+				return
+			}
+			if !isTrusted {
+				invalidScopes = append(invalidScopes, scope)
+			}
+		}
+	}
+	if !hasOpenIDScope {
+		s.tokenErrHelper(w, errInvalidRequest, `Missing required scope(s) ["openid"].`, http.StatusBadRequest)
+		return
+	}
+	if len(unrecognized) > 0 {
+		s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest)
+		return
+	}
+	if len(invalidScopes) > 0 {
+		s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest)
+		return
+	}
+
+	// Which connector
+	connID := s.passwordConnector
+	conn, err := s.getConnector(connID)
+	if err != nil {
+		s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest)
+		return
+	}
+
+	passwordConnector, ok := conn.Connector.(connector.PasswordConnector)
+	if !ok {
+		s.tokenErrHelper(w, errInvalidRequest, "Requested password connector does not correct type.", http.StatusBadRequest)
+		return
+	}
+
+	// Login
+	username := q.Get("username")
+	password := q.Get("password")
+	identity, ok, err := passwordConnector.Login(r.Context(), parseScopes(scopes), username, password)
+	if err != nil {
+		s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest)
+		return
+	}
+	if !ok {
+		s.tokenErrHelper(w, errAccessDenied, "Invalid username or password", http.StatusUnauthorized)
+		return
+	}
+
+	// Build the claims to send the id token
+	claims := storage.Claims{
+		UserID:            identity.UserID,
+		Username:          identity.Username,
+		PreferredUsername: identity.PreferredUsername,
+		Email:             identity.Email,
+		EmailVerified:     identity.EmailVerified,
+		Groups:            identity.Groups,
+	}
+
+	accessToken := storage.NewID()
+	idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, connID)
+	if err != nil {
+		s.tokenErrHelper(w, errServerError, fmt.Sprintf("failed to create ID token: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	reqRefresh := func() bool {
+		// Ensure the connector supports refresh tokens.
+		//
+		// Connectors like `saml` do not implement RefreshConnector.
+		_, ok := conn.Connector.(connector.RefreshConnector)
+		if !ok {
+			return false
+		}
+
+		for _, scope := range scopes {
+			if scope == scopeOfflineAccess {
+				return true
+			}
+		}
+		return false
+	}()
+	var refreshToken string
+	if reqRefresh {
+		refresh := storage.RefreshToken{
+			ID:          storage.NewID(),
+			Token:       storage.NewID(),
+			ClientID:    client.ID,
+			ConnectorID: connID,
+			Scopes:      scopes,
+			Claims:      claims,
+			Nonce:       nonce,
+			// ConnectorData: authCode.ConnectorData,
+			CreatedAt: s.now(),
+			LastUsed:  s.now(),
+		}
+		token := &internal.RefreshToken{
+			RefreshId: refresh.ID,
+			Token:     refresh.Token,
+		}
+		if refreshToken, err = internal.Marshal(token); err != nil {
+			s.logger.Errorf("failed to marshal refresh token: %v", err)
+			s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+			return
+		}
+
+		if err := s.storage.CreateRefresh(refresh); err != nil {
+			s.logger.Errorf("failed to create refresh token: %v", err)
+			s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+			return
+		}
+
+		// deleteToken determines if we need to delete the newly created refresh token
+		// due to a failure in updating/creating the OfflineSession object for the
+		// corresponding user.
+		var deleteToken bool
+		defer func() {
+			if deleteToken {
+				// Delete newly created refresh token from storage.
+				if err := s.storage.DeleteRefresh(refresh.ID); err != nil {
+					s.logger.Errorf("failed to delete refresh token: %v", err)
+					s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+					return
+				}
+			}
+		}()
+
+		tokenRef := storage.RefreshTokenRef{
+			ID:        refresh.ID,
+			ClientID:  refresh.ClientID,
+			CreatedAt: refresh.CreatedAt,
+			LastUsed:  refresh.LastUsed,
+		}
+
+		// Try to retrieve an existing OfflineSession object for the corresponding user.
+		if session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID); err != nil {
+			if err != storage.ErrNotFound {
+				s.logger.Errorf("failed to get offline session: %v", err)
+				s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+				deleteToken = true
+				return
+			}
+			offlineSessions := storage.OfflineSessions{
+				UserID:  refresh.Claims.UserID,
+				ConnID:  refresh.ConnectorID,
+				Refresh: make(map[string]*storage.RefreshTokenRef),
+			}
+			offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef
+
+			// Create a new OfflineSession object for the user and add a reference object for
+			// the newly received refreshtoken.
+			if err := s.storage.CreateOfflineSessions(offlineSessions); err != nil {
+				s.logger.Errorf("failed to create offline session: %v", err)
+				s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+				deleteToken = true
+				return
+			}
+		} else {
+			if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok {
+				// Delete old refresh token from storage.
+				if err := s.storage.DeleteRefresh(oldTokenRef.ID); err != nil {
+					s.logger.Errorf("failed to delete refresh token: %v", err)
+					s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+					deleteToken = true
+					return
+				}
+			}
+
+			// Update existing OfflineSession obj with new RefreshTokenRef.
+			if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
+				old.Refresh[tokenRef.ClientID] = &tokenRef
+				return old, nil
+			}); err != nil {
+				s.logger.Errorf("failed to update offline session: %v", err)
+				s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
+				deleteToken = true
+				return
+			}
+		}
+	}
+
+	s.writeAccessToken(w, idToken, accessToken, refreshToken, expiry)
+}
+
 func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) {
 	resp := struct {
 		AccessToken  string `json:"access_token"`
diff --git a/server/oauth2.go b/server/oauth2.go
index 0cd26814ee10cb69e5ef2ba4f17853b6a276932e..ecc619d669de22e1a69148676a9d268d0f0f4ec4 100644
--- a/server/oauth2.go
+++ b/server/oauth2.go
@@ -121,6 +121,7 @@ const (
 const (
 	grantTypeAuthorizationCode = "authorization_code"
 	grantTypeRefreshToken      = "refresh_token"
+	grantTypePassword          = "password"
 )
 
 const (
diff --git a/server/server.go b/server/server.go
index 21287b65e11ba985e3b22f8f8d5aa3d83aaab5a4..49d177cdae4c1edfd72c683854802e7c44dd2d85 100644
--- a/server/server.go
+++ b/server/server.go
@@ -76,6 +76,8 @@ type Config struct {
 	RotateKeysAfter      time.Duration // Defaults to 6 hours.
 	IDTokensValidFor     time.Duration // Defaults to 24 hours
 	AuthRequestsValidFor time.Duration // Defaults to 24 hours
+	// If set, the server will use this connector to handle password grants
+	PasswordConnector string
 
 	GCFrequency time.Duration // Defaults to 5 minutes
 
@@ -145,6 +147,9 @@ type Server struct {
 	// If enabled, show the connector selection screen even if there's only one
 	alwaysShowLogin bool
 
+	// Used for password grant
+	passwordConnector string
+
 	supportedResponseTypes map[string]bool
 
 	now func() time.Time
@@ -216,6 +221,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
 		alwaysShowLogin:        c.AlwaysShowLoginScreen,
 		now:                    now,
 		templates:              tmpls,
+		passwordConnector:      c.PasswordConnector,
 		logger:                 c.Logger,
 	}