diff --git a/connector/connector.go b/connector/connector.go
index 95a7ec139728d28baf4dfcaf84e76eb801bcf5ba..c92d7589e3e62962d34a497a998b1230d4745d44 100644
--- a/connector/connector.go
+++ b/connector/connector.go
@@ -66,6 +66,23 @@ type CallbackConnector interface {
 	HandleCallback(s Scopes, r *http.Request) (identity Identity, err error)
 }
 
+// SAMLConnector represents SAML connectors which implement the HTTP POST binding.
+//
+// RelayState is handled by the server.
+type SAMLConnector interface {
+	// POSTData returns an encoded SAML request and SSO URL for the server to
+	// render a POST form with.
+	POSTData(s Scopes) (sooURL, samlRequest string, err error)
+
+	// TODO(ericchiang): Provide expected "InResponseTo" ID value.
+	//
+	// See: https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf
+	// "3.2.2 Complex Type StatusResponseType"
+
+	// HandlePOST decodes, verifies, and maps attributes from the SAML response.
+	HandlePOST(s Scopes, samlResponse string) (identity Identity, err error)
+}
+
 // RefreshConnector is a connector that can update the client claims.
 type RefreshConnector interface {
 	// Refresh is called when a client attempts to claim a refresh token. The
diff --git a/connector/saml/saml.go b/connector/saml/saml.go
new file mode 100644
index 0000000000000000000000000000000000000000..0c5b806bcbbe5369e014993ad12e4ac88fac6b0d
--- /dev/null
+++ b/connector/saml/saml.go
@@ -0,0 +1,387 @@
+// Package saml contains login methods for SAML.
+package saml
+
+import (
+	"bytes"
+	"compress/flate"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/pem"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"strings"
+	"time"
+
+	"github.com/Sirupsen/logrus"
+	"github.com/beevik/etree"
+	dsig "github.com/russellhaering/goxmldsig"
+
+	"github.com/coreos/dex/connector"
+)
+
+const (
+	bindingRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+	bindingPOST     = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+
+	nameIDFormatEmailAddress = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
+	nameIDFormatUnspecified  = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
+	nameIDFormatX509Subject  = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName"
+	nameIDFormatWindowsDN    = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName"
+	nameIDFormatEncrypted    = "urn:oasis:names:tc:SAML:2.0:nameid-format:encrypted"
+	nameIDFormatEntity       = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
+	nameIDFormatKerberos     = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos"
+	nameIDFormatPersistent   = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
+	nameIDformatTransient    = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
+)
+
+var (
+	nameIDFormats = []string{
+		nameIDFormatEmailAddress,
+		nameIDFormatUnspecified,
+		nameIDFormatX509Subject,
+		nameIDFormatWindowsDN,
+		nameIDFormatEncrypted,
+		nameIDFormatEntity,
+		nameIDFormatKerberos,
+		nameIDFormatPersistent,
+		nameIDformatTransient,
+	}
+	nameIDFormatLookup = make(map[string]string)
+)
+
+func init() {
+	suffix := func(s, sep string) string {
+		if i := strings.LastIndex(s, sep); i > 0 {
+			return s[i+1:]
+		}
+		return s
+	}
+	for _, format := range nameIDFormats {
+		nameIDFormatLookup[suffix(format, ":")] = format
+		nameIDFormatLookup[format] = format
+	}
+}
+
+// Config represents configuration options for the SAML provider.
+type Config struct {
+	// TODO(ericchiang): A bunch of these fields could be auto-filled if
+	// we supported SAML metadata discovery.
+	//
+	// https://www.oasis-open.org/committees/download.php/35391/sstc-saml-metadata-errata-2.0-wd-04-diff.pdf
+
+	Issuer string `json:"issuer"`
+	SSOURL string `json:"ssoURL"`
+
+	// X509 CA file or raw data to verify XML signatures.
+	CA     string `json:"ca"`
+	CAData []byte `json:"caData"`
+
+	InsecureSkipSignatureValidation bool `json:"insecureSkipSignatureValidation"`
+
+	// Assertion attribute names to lookup various claims with.
+	UsernameAttr string `json:"usernameAttr"`
+	EmailAttr    string `json:"emailAttr"`
+	GroupsAttr   string `json:"groupsAttr"`
+	// If GroupsDelim is supplied the connector assumes groups are returned as a
+	// single string instead of multiple attribute values. This delimiter will be
+	// used split the groups string.
+	GroupsDelim string `json:"groupsDelim"`
+
+	RedirectURI string `json:"redirectURI"`
+
+	// 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 string `json:"nameIDPolicyFormat"`
+}
+
+type certStore struct {
+	certs []*x509.Certificate
+}
+
+func (c certStore) Certificates() (roots []*x509.Certificate, err error) {
+	return c.certs, nil
+}
+
+// Open validates the config and returns a connector. It does not actually
+// validate connectivity with the provider.
+func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
+	return c.openConnector(logger)
+}
+
+func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
+	connector.SAMLConnector
+}, error) {
+	requiredFields := []struct {
+		name, val string
+	}{
+		{"issuer", c.Issuer},
+		{"ssoURL", c.SSOURL},
+		{"usernameAttr", c.UsernameAttr},
+		{"emailAttr", c.EmailAttr},
+		{"redirectURI", c.RedirectURI},
+	}
+	var missing []string
+	for _, f := range requiredFields {
+		if f.val == "" {
+			missing = append(missing, f.name)
+		}
+	}
+	switch len(missing) {
+	case 0:
+	case 1:
+		return nil, fmt.Errorf("missing required field %q", missing[0])
+	default:
+		return nil, fmt.Errorf("missing required fields %q", missing)
+	}
+
+	p := &provider{
+		issuer:       c.Issuer,
+		ssoURL:       c.SSOURL,
+		now:          time.Now,
+		usernameAttr: c.UsernameAttr,
+		emailAttr:    c.EmailAttr,
+		groupsAttr:   c.GroupsAttr,
+		groupsDelim:  c.GroupsDelim,
+		redirectURI:  c.RedirectURI,
+		logger:       logger,
+
+		nameIDPolicyFormat: c.NameIDPolicyFormat,
+	}
+
+	if p.nameIDPolicyFormat == "" {
+		p.nameIDPolicyFormat = nameIDFormatPersistent
+	} else {
+		if format, ok := nameIDFormatLookup[p.nameIDPolicyFormat]; ok {
+			p.nameIDPolicyFormat = format
+		} else {
+			return nil, fmt.Errorf("invalid nameIDPolicyFormat: %q", p.nameIDPolicyFormat)
+		}
+	}
+
+	if !c.InsecureSkipSignatureValidation {
+		if (c.CA == "") == (c.CAData == nil) {
+			return nil, errors.New("must provide either 'ca' or 'caData'")
+		}
+
+		var caData []byte
+		if c.CA != "" {
+			data, err := ioutil.ReadFile(c.CA)
+			if err != nil {
+				return nil, fmt.Errorf("read ca file: %v", err)
+			}
+			caData = data
+		} else {
+			caData = c.CAData
+		}
+
+		var (
+			certs []*x509.Certificate
+			block *pem.Block
+		)
+		for {
+			block, caData = pem.Decode(caData)
+			if block == nil {
+				break
+			}
+			cert, err := x509.ParseCertificate(block.Bytes)
+			if err != nil {
+				return nil, fmt.Errorf("parse cert: %v", err)
+			}
+			certs = append(certs, cert)
+		}
+		if len(certs) == 0 {
+			return nil, errors.New("no certificates found in ca data")
+		}
+		p.validator = dsig.NewDefaultValidationContext(certStore{certs})
+	}
+	return p, nil
+}
+
+type provider struct {
+	issuer string
+	ssoURL string
+
+	now func() time.Time
+
+	// If nil, don't do signature validation.
+	validator *dsig.ValidationContext
+
+	// Attribute mappings
+	usernameAttr string
+	emailAttr    string
+	groupsAttr   string
+	groupsDelim  string
+
+	redirectURI string
+
+	nameIDPolicyFormat string
+
+	logger logrus.FieldLogger
+}
+
+func (p *provider) POSTData(s connector.Scopes) (action, value string, err error) {
+
+	// NOTE(ericchiang): If we can't follow up with the identity provider, can we
+	// support refresh tokens?
+	if s.OfflineAccess {
+		return "", "", fmt.Errorf("SAML does not support offline access")
+	}
+
+	r := &authnRequest{
+		ProtocolBinding: bindingPOST,
+		ID:              "_" + uuidv4(),
+		IssueInstant:    xmlTime(p.now()),
+		Destination:     p.ssoURL,
+		Issuer: &issuer{
+			Issuer: p.issuer,
+		},
+		NameIDPolicy: &nameIDPolicy{
+			AllowCreate: true,
+			Format:      p.nameIDPolicyFormat,
+		},
+	}
+
+	data, err := xml.MarshalIndent(r, "", "  ")
+	if err != nil {
+		return "", "", fmt.Errorf("marshal authn request: %v", err)
+	}
+
+	buff := new(bytes.Buffer)
+	fw, err := flate.NewWriter(buff, flate.DefaultCompression)
+	if err != nil {
+		return "", "", fmt.Errorf("new flate writer: %v", err)
+	}
+	if _, err := fw.Write(data); err != nil {
+		return "", "", fmt.Errorf("compress message: %v", err)
+	}
+	if err := fw.Close(); err != nil {
+		return "", "", fmt.Errorf("flush message: %v", err)
+	}
+
+	return p.ssoURL, base64.StdEncoding.EncodeToString(buff.Bytes()), nil
+}
+
+func (p *provider) HandlePOST(s connector.Scopes, samlResponse string) (ident connector.Identity, err error) {
+	rawResp, err := base64.StdEncoding.DecodeString(samlResponse)
+	if err != nil {
+		return ident, fmt.Errorf("decode response: %v", err)
+	}
+	if p.validator != nil {
+		if rawResp, err = verify(p.validator, rawResp); err != nil {
+			return ident, fmt.Errorf("verify signature: %v", err)
+		}
+	}
+
+	var resp response
+	if err := xml.Unmarshal(rawResp, &resp); err != nil {
+		return ident, fmt.Errorf("unmarshal response: %v", err)
+	}
+
+	if resp.Destination != "" && resp.Destination != p.redirectURI {
+		return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination)
+
+	}
+
+	assertion := resp.Assertion
+	if assertion == nil {
+		return ident, fmt.Errorf("response did not contain an assertion")
+	}
+	subject := assertion.Subject
+	if subject == nil {
+		return ident, fmt.Errorf("response did not contain a subject")
+	}
+
+	switch {
+	case subject.NameID != nil:
+		if ident.UserID = subject.NameID.Value; ident.UserID == "" {
+			return ident, fmt.Errorf("NameID element does not contain a value")
+		}
+	default:
+		return ident, fmt.Errorf("subject does not contain an NameID element")
+	}
+
+	attributes := assertion.AttributeStatement
+	if attributes == nil {
+		return ident, fmt.Errorf("response did not contain a AttributeStatement")
+	}
+
+	if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" {
+		return ident, fmt.Errorf("no attribute with name %q", p.emailAttr)
+	}
+	ident.EmailVerified = true
+
+	if ident.Username, _ = attributes.get(p.usernameAttr); ident.Username == "" {
+		return ident, fmt.Errorf("no attribute with name %q", p.usernameAttr)
+	}
+
+	if s.Groups && p.groupsAttr != "" {
+		if p.groupsDelim != "" {
+			groupsStr, ok := attributes.get(p.groupsAttr)
+			if !ok {
+				return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr)
+			}
+			// TOOD(ericchiang): Do we need to further trim whitespace?
+			ident.Groups = strings.Split(groupsStr, p.groupsDelim)
+		} else {
+			groups, ok := attributes.all(p.groupsAttr)
+			if !ok {
+				return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr)
+			}
+			ident.Groups = groups
+		}
+	}
+
+	return ident, nil
+}
+
+// verify checks the signature info of a XML document and returns
+// the signed elements.
+func verify(validator *dsig.ValidationContext, data []byte) (signed []byte, err error) {
+	doc := etree.NewDocument()
+	if err := doc.ReadFromBytes(data); err != nil {
+		return nil, fmt.Errorf("parse document: %v", err)
+	}
+
+	result, err := validator.Validate(doc.Root())
+	if err != nil {
+		return nil, err
+	}
+	doc.SetRoot(result)
+	return doc.WriteToBytes()
+}
+
+func uuidv4() string {
+	u := make([]byte, 16)
+	if _, err := rand.Read(u); err != nil {
+		panic(err)
+	}
+	u[6] = (u[6] | 0x40) & 0x4F
+	u[8] = (u[8] | 0x80) & 0xBF
+
+	r := make([]byte, 36)
+	r[8] = '-'
+	r[13] = '-'
+	r[18] = '-'
+	r[23] = '-'
+	hex.Encode(r, u[0:4])
+	hex.Encode(r[9:], u[4:6])
+	hex.Encode(r[14:], u[6:8])
+	hex.Encode(r[19:], u[8:10])
+	hex.Encode(r[24:], u[10:])
+
+	return string(r)
+}
diff --git a/connector/saml/saml_test.go b/connector/saml/saml_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..4e45568866035da06559fc0f0dfa441e86e6db85
--- /dev/null
+++ b/connector/saml/saml_test.go
@@ -0,0 +1,42 @@
+package saml
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"io/ioutil"
+	"testing"
+
+	sdig "github.com/russellhaering/goxmldsig"
+)
+
+func loadCert(ca string) (*x509.Certificate, error) {
+	data, err := ioutil.ReadFile(ca)
+	if err != nil {
+		return nil, err
+	}
+	block, _ := pem.Decode(data)
+	if block == nil {
+		return nil, errors.New("ca file didn't contain any PEM data")
+	}
+	return x509.ParseCertificate(block.Bytes)
+}
+
+func TestVerify(t *testing.T) {
+	cert, err := loadCert("testdata/okta-ca.pem")
+	if err != nil {
+		t.Fatal(err)
+	}
+	s := certStore{[]*x509.Certificate{cert}}
+
+	validator := sdig.NewDefaultValidationContext(s)
+
+	data, err := ioutil.ReadFile("testdata/okta-resp.xml")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := verify(validator, data); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/connector/saml/testdata/okta-ca.pem b/connector/saml/testdata/okta-ca.pem
new file mode 100644
index 0000000000000000000000000000000000000000..de7f1c88b351ce162c6f7b86e45dd3882b7c2f6d
--- /dev/null
+++ b/connector/saml/testdata/okta-ca.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG
+A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
+MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW
+DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE
+BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV
+BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ
+KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0
+m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD
+eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt
+46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1
+51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj
+7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo
+u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp
+eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL
+rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE
+mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy
+-----END CERTIFICATE-----
diff --git a/connector/saml/testdata/okta-resp.xml b/connector/saml/testdata/okta-resp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9dff8e101bbe13d1ef48e257a50ad82b972d54fa
--- /dev/null
+++ b/connector/saml/testdata/okta-resp.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?><saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://localhost:5556/dex/callback" ID="id108965453120986171998428970" InResponseTo="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" IssueInstant="2016-12-20T22:18:23.771Z" Version="2.0"><saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id108965453120986171998428970"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>Phu93l0D97JSMIYDZBdVeNLN0pwBVHhzUDWxbh4sc6g=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>M2gMHOmnMAFgh2apq/2jHwDYmisUkYMUqxrWkQJf3RHFotl4EeDlcqq/FzOboJc3NcbKBqQY3CWsWhWh5cNWHDgNneaahW4czww+9DCM0R/zz5c6GuMYFEh5df2sDn/dWk/jbKMiAMgPdKJ2x/+5Xk9q4axC52TdQrrbZtzAAAn4CgrT6Kf11qfMl5wpDarg3qPw7ANxWn2DKzCsvCkOIwM2+AXh+sEXmTvvZIQ0vpv098FH/ZTGt4sCwb1bmRZ3UZLhBcxVc/sjuEW/sQ6pbQHkjrXIR5bxXzGNUxYpcGjrp9HGF+In0BAc+Ds/A0H142e1rgtcX8LH2pbG8URJSQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG
+A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
+MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW
+DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE
+BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV
+BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ
+KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0
+m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD
+eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt
+46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1
+51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj
+7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo
+u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp
+eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL
+rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE
+mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2p:Status><saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="id10896545312129779529177535" IssueInstant="2016-12-20T22:18:23.771Z" Version="2.0"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id10896545312129779529177535"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ufwWUjecX6I/aQb4WW9P9ZMLG3C8hN6LaZyyb/EATIs=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>jKtNBzxAL67ssuzWkkbf0yzqRyZ51y2JjBQ9C6bW8io/JOYQB2v7Bix7Eu/RjJslO7OBqD+3tPrK7ZBOy2+LFuAh3cDNa3U5NhO0raLrn/2YoJXfjj3XX3hyQv6GVxo0EY1KJNXOzWxjp9RVDpHslPTIL1yDC/oy0Mlzxu6pXBEerz9J2/Caenq66Skb5/DAT8FvrJ2s1bxuMagShs3APhC1hD8mvktZ+ZcN8ujs2SebteGK4IoOCx+e8+v2CyycBv1l5l+v5I+D2HnbAw4LfvHnW4rZOJT2AvoI47p1YBK1qDsJutG3jUPKy4Yx5YF73Xi1oytr+rrHyx/lfFPd2A==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG
+A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
+MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW
+DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE
+BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV
+BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ
+KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0
+m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD
+eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt
+46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1
+51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj
+7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo
+u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp
+eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL
+rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE
+mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">eric.chiang+okta@coreos.com</saml2:NameID><saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml2:SubjectConfirmationData InResponseTo="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" NotOnOrAfter="2016-12-20T22:23:23.772Z" Recipient="http://localhost:5556/dex/callback"/></saml2:SubjectConfirmation></saml2:Subject><saml2:Conditions NotBefore="2016-12-20T22:13:23.772Z" NotOnOrAfter="2016-12-20T22:23:23.772Z" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AudienceRestriction><saml2:Audience>http://localhost:5556/dex/callback</saml2:Audience></saml2:AudienceRestriction></saml2:Conditions><saml2:AuthnStatement AuthnInstant="2016-12-20T22:18:23.771Z" SessionIndex="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement></saml2:Assertion></saml2p:Response>
diff --git a/connector/saml/types.go b/connector/saml/types.go
new file mode 100644
index 0000000000000000000000000000000000000000..7c1d89be1e227cff4bbf3b5cb106e2b8e89d4541
--- /dev/null
+++ b/connector/saml/types.go
@@ -0,0 +1,177 @@
+package saml
+
+import (
+	"encoding/xml"
+	"fmt"
+	"time"
+)
+
+const timeFormat = "2006-01-02T15:04:05Z"
+
+type xmlTime time.Time
+
+func (t xmlTime) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
+	return xml.Attr{
+		Name:  name,
+		Value: time.Time(t).UTC().Format(timeFormat),
+	}, nil
+}
+
+func (t *xmlTime) UnmarshalXMLAttr(attr xml.Attr) error {
+	got, err := time.Parse(timeFormat, attr.Value)
+	if err != nil {
+		return err
+	}
+	*t = xmlTime(got)
+	return nil
+}
+
+type samlVersion struct{}
+
+func (s samlVersion) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
+	return xml.Attr{
+		Name:  name,
+		Value: "2.0",
+	}, nil
+}
+
+func (s *samlVersion) UnmarshalXMLAttr(attr xml.Attr) error {
+	if attr.Value != "2.0" {
+		return fmt.Errorf(`saml version expected "2.0" got %q`, attr.Value)
+	}
+	return nil
+}
+
+type authnRequest struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnRequest"`
+
+	ID      string      `xml:"ID,attr"`
+	Version samlVersion `xml:"Version,attr"`
+
+	ProviderName string  `xml:"ProviderName,attr,omitempty"`
+	IssueInstant xmlTime `xml:"IssueInstant,attr,omitempty"`
+	Consent      bool    `xml:"Consent,attr,omitempty"`
+	Destination  string  `xml:"Destination,attr,omitempty"`
+
+	ForceAuthn      bool   `xml:"ForceAuthn,attr,omitempty"`
+	IsPassive       bool   `xml:"IsPassive,attr,omitempty"`
+	ProtocolBinding string `xml:"ProtocolBinding,attr,omitempty"`
+
+	Subject      *subject      `xml:"Subject,omitempty"`
+	Issuer       *issuer       `xml:"Issuer,omitempty"`
+	NameIDPolicy *nameIDPolicy `xml:"NameIDPolicy,omitempty"`
+
+	// TODO(ericchiang): Make this configurable and determine appropriate default values.
+	RequestAuthnContext *requestAuthnContext `xml:"RequestAuthnContext,omitempty"`
+}
+
+type subject struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"`
+
+	NameID *nameID `xml:"NameID,omitempty"`
+
+	// TODO(ericchiang): Do we need to deal with baseID?
+}
+
+type nameID struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion NameID"`
+
+	Format string `xml:"Format,omitempty"`
+	Value  string `xml:",chardata"`
+}
+
+type issuer struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"`
+	Issuer  string   `xml:",chardata"`
+}
+
+type nameIDPolicy struct {
+	XMLName     xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol NameIDPolicy"`
+	AllowCreate bool     `xml:"AllowCreate,attr,omitempty"`
+	Format      string   `xml:"Format,attr,omitempty"`
+}
+
+type requestAuthnContext struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequestAuthnContext"`
+
+	AuthnContextClassRefs []authnContextClassRef
+}
+
+type authnContextClassRef struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnContextClassRef"`
+	Value   string   `xml:",chardata"`
+}
+
+type response struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"`
+
+	ID      string      `xml:"ID,attr"`
+	Version samlVersion `xml:"Version,attr"`
+
+	Destination string `xml:"Destination,attr,omitempty"`
+
+	Issuer *issuer `xml:"Issuer,omitempty"`
+
+	// TODO(ericchiang): How do deal with multiple assertions?
+	Assertion *assertion `xml:"Assertion,omitempty"`
+}
+
+type assertion struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"`
+
+	Version       samlVersion `xml:"Version,attr"`
+	ID            string      `xml:"ID,attr"`
+	IssueInstance xmlTime     `xml:"IssueInstance,attr"`
+
+	Issuer issuer `xml:"Issuer"`
+
+	Subject *subject `xml:"Subject,omitempty"`
+
+	AttributeStatement *attributeStatement `xml:"AttributeStatement,omitempty"`
+}
+
+type attributeStatement struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeStatement"`
+
+	Attributes []attribute `xml:"Attribute"`
+}
+
+func (a *attributeStatement) get(name string) (s string, ok bool) {
+	for _, attr := range a.Attributes {
+		if attr.Name == name {
+			ok = true
+			if len(attr.AttributeValues) > 0 {
+				return attr.AttributeValues[0].Value, true
+			}
+		}
+	}
+	return
+}
+
+func (a *attributeStatement) all(name string) (s []string, ok bool) {
+	for _, attr := range a.Attributes {
+		if attr.Name == name {
+			ok = true
+			for _, val := range attr.AttributeValues {
+				s = append(s, val.Value)
+			}
+		}
+	}
+	return
+}
+
+type attribute struct {
+	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`
+
+	Name string `xml:"Name,attr"`
+
+	NameFormat   string `xml:"NameFormat,attr,omitempty"`
+	FriendlyName string `xml:"FriendlyName,attr,omitempty"`
+
+	AttributeValues []attributeValue `xml:"AttributeValue,omitempty"`
+}
+
+type attributeValue struct {
+	XMLName xml.Name `xml:"AttributeValue"`
+	Value   string   `xml:",chardata"`
+}
diff --git a/glide.yaml b/glide.yaml
index 05d6ec4216ef4b3ebec84dab89b53923b0628bbf..3e7d13e6ae4aab0ae621377a6c2fe852ca3b3e57 100644
--- a/glide.yaml
+++ b/glide.yaml
@@ -131,3 +131,11 @@ import:
   version: v0.11.0
 - package: golang.org/x/sys/unix
   version: 833a04a10549a95dc34458c195cbad61bbb6cb4d
+
+# XML signature validation for SAML connector
+- package: github.com/russellhaering/goxmldsig
+  version: d9f653eb27ee8b145f7d5a45172e81a93def0860
+- package: github.com/beevik/etree
+  version: 4cd0dd976db869f817248477718071a28e978df0
+- package: github.com/jonboulle/clockwork
+  version: bcac9884e7502bb2b474c0339d889cb981a2f27f