diff --git a/Documentation/saml-connector.md b/Documentation/saml-connector.md
new file mode 100644
index 0000000000000000000000000000000000000000..d78a6f2b255fb7046f6b870aa09aa98ada4d51f5
--- /dev/null
+++ b/Documentation/saml-connector.md
@@ -0,0 +1,72 @@
+# Authentication through SAML 2.0
+
+## Overview
+
+The experimental SAML provider allows authentication through the SAML 2.0 HTTP POST binding.
+
+The connector uses the value of the `NameID` element as the user's unique identifier which dex assumes is both unique and never changes. Use the `nameIDPolicyFormat` to ensure this is set to a value which satisfies these requirements.
+
+## Caveats
+
+There are known issues with the XML signature validation for this connector. In addition work is still being done to ensure this connector implements best security practices for SAML 2.0.
+
+The connector doesn't support signed AuthnRequests or encrypted attributes.
+
+The connector doesn't support refresh tokens since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction.
+
+## Configuration
+
+```yaml
+connectors:
+- type: samlExperimental # will be changed to "saml" later without support for the "samlExperimental" value
+  id: saml
+  config:
+    # Issuer used for validating the SAML response.
+    issuer: https://saml.example.com
+    # SSO URL used for POST value.
+    ssoURL: https://saml.example.com/sso
+
+    # CA to use when validating the SAML response.
+    ca: /path/to/ca.pem
+
+    # CA's can also be provided inline as a base64'd blob. 
+    #
+    # catData: ( RAW base64'd PEM encoded CA )
+
+    # To skip signature validation, uncomment the following field. This should
+    # only be used during testing and may be removed in the future.
+    # 
+    # insucreSkipSignatureValidation: true
+
+    # Dex's callback URL. Must match the "Destination" attribute of all responses
+    # exactly.  
+    redirectURI: https://dex.example.com/callback
+
+    # Name of attributes in the returned assertions to map to ID token claims.
+    usernameAttr: name
+    emailAttr: email
+    groupsAttr: groups # optional
+
+    # By default, multiple groups are assumed to be represented as multiple
+    # attributes with the same name.
+    #
+    # If "groupsDelim" is provided groups are assumed to be represented as a
+    # single attribute and the delimiter is used to split the attribute's value
+    # into multiple groups.
+    #
+    # groupsDelim: ", "
+
+
+    # Requested format of the NameID. The NameID value is is mapped to the ID Token
+    # 'sub' claim.  This can be an abbreviated form of the full URI with just the last
+    # component. For example, if this value is set to "emailAddress" the format will
+    # resolve to:
+    #
+    #     urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+    #
+    # If no value is specified, this value defaults to:
+    #
+    #     urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+    #
+    nameIDPolicyFormat: persistent
+```
diff --git a/README.md b/README.md
index 2812b2ac46c4f03bde630fa4bd7d46e91ab687aa..ca3e08d7b4b9fee5b1f15c8ce16370faa531c698 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ More docs for running dex as a Kubernetes authenticator can be found [here](Docu
 * Identity provider logins
   * [LDAP](Documentation/ldap-connector.md)
   * [GitHub](Documentation/github-connector.md)
+  * [SAML 2.0 (experimental)](Documentation/saml-connector.md)
 * Client libraries
   * [Go][go-oidc]
 
diff --git a/cmd/dex/config.go b/cmd/dex/config.go
index 40a4fe612097d2b6d2bed4ecc2ca01bea5c590a4..19a87fc3bc226dfa269218da0e2e2e1537633728 100644
--- a/cmd/dex/config.go
+++ b/cmd/dex/config.go
@@ -14,6 +14,7 @@ import (
 	"github.com/coreos/dex/connector/ldap"
 	"github.com/coreos/dex/connector/mock"
 	"github.com/coreos/dex/connector/oidc"
+	"github.com/coreos/dex/connector/saml"
 	"github.com/coreos/dex/server"
 	"github.com/coreos/dex/storage"
 	"github.com/coreos/dex/storage/kubernetes"
@@ -177,11 +178,12 @@ type ConnectorConfig interface {
 }
 
 var connectors = map[string]func() ConnectorConfig{
-	"mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) },
-	"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
-	"ldap":         func() ConnectorConfig { return new(ldap.Config) },
-	"github":       func() ConnectorConfig { return new(github.Config) },
-	"oidc":         func() ConnectorConfig { return new(oidc.Config) },
+	"mockCallback":     func() ConnectorConfig { return new(mock.CallbackConfig) },
+	"mockPassword":     func() ConnectorConfig { return new(mock.PasswordConfig) },
+	"ldap":             func() ConnectorConfig { return new(ldap.Config) },
+	"github":           func() ConnectorConfig { return new(github.Config) },
+	"oidc":             func() ConnectorConfig { return new(oidc.Config) },
+	"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
 }
 
 // UnmarshalJSON allows Connector to implement the unmarshaler interface to
diff --git a/server/handlers.go b/server/handlers.go
index c962265fe7325d9165b69821a37b4fff8e73def1..5a0b9b34574d7d512bdb8ecadd131ec64692827c 100644
--- a/server/handlers.go
+++ b/server/handlers.go
@@ -227,6 +227,31 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
 			if err := s.templates.password(w, authReqID, r.URL.String(), "", false); err != nil {
 				s.logger.Errorf("Server template error: %v", err)
 			}
+		case connector.SAMLConnector:
+			action, value, err := conn.POSTData(scopes)
+			if err != nil {
+				s.logger.Errorf("Creating SAML data: %v", err)
+				s.renderError(w, http.StatusInternalServerError, "Connector Login Error")
+				return
+			}
+
+			// TODO(ericchiang): Don't inline this.
+			fmt.Fprintf(w, `<!DOCTYPE html>
+			  <html lang="en">
+			  <head>
+			    <meta http-equiv="content-type" content="text/html; charset=utf-8">
+			    <title>SAML login</title>
+			  </head>
+			  <body>
+			    <form method="post" action="%s" >
+				    <input type="hidden" name="SAMLRequest" value="%s" />
+				    <input type="hidden" name="RelayState" value="%s" />
+			    </form>
+				<script>
+				    document.forms[0].submit();
+				</script>
+			  </body>
+			  </html>`, action, value, authReqID)
 		default:
 			s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.")
 		}
@@ -266,20 +291,24 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
-	// SAML redirect bindings use the "RelayState" URL query field. When we support
-	// SAML, we'll have to check that field too and possibly let callback connectors
-	// indicate which field is used to determine the state.
-	//
-	// See:
-	//   https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
-	//   Section: "3.4.3 RelayState"
-	state := r.URL.Query().Get("state")
-	if state == "" {
-		s.renderError(w, http.StatusBadRequest, "User session error.")
+	var authID string
+	switch r.Method {
+	case "GET": // OAuth2 callback
+		if authID = r.URL.Query().Get("state"); authID == "" {
+			s.renderError(w, http.StatusBadRequest, "User session error.")
+			return
+		}
+	case "POST": // SAML POST binding
+		if authID = r.PostFormValue("RelayState"); authID == "" {
+			s.renderError(w, http.StatusBadRequest, "User session error.")
+			return
+		}
+	default:
+		s.renderError(w, http.StatusBadRequest, "Method not supported")
 		return
 	}
 
-	authReq, err := s.storage.GetAuthRequest(state)
+	authReq, err := s.storage.GetAuthRequest(authID)
 	if err != nil {
 		if err == storage.ErrNotFound {
 			s.logger.Errorf("Invalid 'state' parameter provided: %v", err)
@@ -296,13 +325,28 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
 		s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
 		return
 	}
-	callbackConnector, ok := conn.Connector.(connector.CallbackConnector)
-	if !ok {
+
+	var identity connector.Identity
+	switch conn := conn.Connector.(type) {
+	case connector.CallbackConnector:
+		if r.Method != "GET" {
+			s.logger.Errorf("SAML request mapped to OAuth2 connector")
+			s.renderError(w, http.StatusBadRequest, "Invalid request")
+			return
+		}
+		identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r)
+	case connector.SAMLConnector:
+		if r.Method != "POST" {
+			s.logger.Errorf("OAuth2 request mapped to SAML connector")
+			s.renderError(w, http.StatusBadRequest, "Invalid request")
+			return
+		}
+		identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"))
+	default:
 		s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
 		return
 	}
 
-	identity, err := callbackConnector.HandleCallback(parseScopes(authReq.Scopes), r)
 	if err != nil {
 		s.logger.Errorf("Failed to authenticate: %v", err)
 		s.renderError(w, http.StatusInternalServerError, "Failed to return user's identity.")