diff --git a/Makefile b/Makefile
index 037a26d2c266659833da760943773512acf701c4..a8a4d7f77b3ecf708f8b7d2dad9f8b3b21ca47ea 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ assets: httpbin/assets/*
 	go-bindata -o httpbin/assets.go -pkg=httpbin -prefix=httpbin/assets httpbin/assets
 
 test: assets
-	go test github.com/mccutchen/go-httpbin/httpbin
+	go test ./...
 
 testcover: assets
 	mkdir -p dist
diff --git a/httpbin/digest/digest.go b/httpbin/digest/digest.go
index 7baffc1a30108e820de80af585c0540d06a2bc11..13c09e1306333184a1049955cafe4c2ef82cb8d7 100644
--- a/httpbin/digest/digest.go
+++ b/httpbin/digest/digest.go
@@ -24,11 +24,9 @@ import (
 
 // Check returns a bool indicating whether the request is correctly
 // authenticated for the given username and password.
-//
-// TODO: use constant-time equality comparison.
 func Check(req *http.Request, username, password string) bool {
 	auth := parseAuthorizationHeader(req.Header.Get("Authorization"))
-	if auth == nil {
+	if auth == nil || auth.username != username {
 		return false
 	}
 	expectedResponse := response(auth, password, req.Method, req.RequestURI)
@@ -116,19 +114,22 @@ func parseAuthorizationHeader(value string) *authorization {
 // parseDictHeader is a simplistic, buggy, and incomplete implementation of
 // parsing key-value pairs from a header value into a map.
 func parseDictHeader(value string) map[string]string {
-	res := make(map[string]string)
-	for _, pair := range strings.Split(value, ",") {
-		parts := strings.SplitN(pair, "=", 2)
-		if len(parts) == 1 {
-			res[parts[0]] = ""
+	pairs := strings.Split(value, ",")
+	res := make(map[string]string, len(pairs))
+	for _, pair := range pairs {
+		parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
+		key := strings.TrimSpace(parts[0])
+		if len(key) == 0 {
 			continue
 		}
-		key := parts[0]
-		val := parts[1]
-		if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) {
-			val = val[1 : len(val)-1]
+		val := ""
+		if len(parts) > 1 {
+			val = strings.TrimSpace(parts[1])
+			if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) {
+				val = val[1 : len(val)-1]
+			}
 		}
-		res[strings.TrimSpace(key)] = strings.TrimSpace(val)
+		res[key] = val
 	}
 	return res
 }
diff --git a/httpbin/digest/digest_test.go b/httpbin/digest/digest_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..a359f1ac0fd4e34455a8bd72470bf62853ea3e14
--- /dev/null
+++ b/httpbin/digest/digest_test.go
@@ -0,0 +1,253 @@
+package digest
+
+import (
+	"crypto"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+)
+
+// Well-formed examples from Wikipedia:
+// https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation
+const (
+	exampleUsername = "Mufasa"
+	examplePassword = "Circle Of Life"
+
+	exampleChallenge string = `Digest realm="testrealm@host.com",
+            qop="auth,auth-int",
+            nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
+            opaque="5ccc069c403ebaf9f0171e9517f40e41"`
+
+	exampleAuthorization string = `Digest username="Mufasa",
+			realm="testrealm@host.com",
+			nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
+			uri="/dir/index.html",
+			qop=auth,
+			nc=00000001,
+			cnonce="0a4f113b",
+			response="6629fae49393a05397450978507c4ef1",
+			opaque="5ccc069c403ebaf9f0171e9517f40e41"`
+)
+
+func assertStringEquals(t *testing.T, expected, got string) {
+	if expected != got {
+		t.Errorf("Expected %#v, got %#v", expected, got)
+	}
+}
+
+func buildRequest(method, uri, authHeader string) *http.Request {
+	req, _ := http.NewRequest(method, uri, nil)
+	req.RequestURI = uri
+	if authHeader != "" {
+		req.Header.Set("Authorization", authHeader)
+	}
+	return req
+}
+
+func TestCheck(t *testing.T) {
+	t.Run("missing authorization", func(t *testing.T) {
+		req := buildRequest("GET", "/dir/index.html", "")
+		if Check(req, exampleUsername, examplePassword) != false {
+			t.Error("Missing Authorization header should fail")
+		}
+	})
+
+	t.Run("wrong username", func(t *testing.T) {
+		req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
+		if Check(req, "Simba", examplePassword) != false {
+			t.Error("Incorrect username should fail")
+		}
+	})
+
+	t.Run("wrong password", func(t *testing.T) {
+		req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
+		if Check(req, examplePassword, "foobar") != false {
+			t.Error("Incorrect password should fail")
+		}
+	})
+
+	t.Run("ok", func(t *testing.T) {
+		req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
+		if Check(req, exampleUsername, examplePassword) != true {
+			t.Error("Correct credentials should pass")
+		}
+	})
+}
+
+func TestResponse(t *testing.T) {
+	auth := parseAuthorizationHeader(exampleAuthorization)
+	expected := auth.response
+	got := response(auth, examplePassword, "GET", "/dir/index.html")
+	assertStringEquals(t, expected, got)
+}
+
+func TestHash(t *testing.T) {
+	var tests = []struct {
+		algorithm crypto.Hash
+		data      []byte
+		expected  string
+	}{
+		{crypto.SHA256, []byte("hello, world!\n"), "4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc"},
+		{crypto.MD5, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"},
+
+		// Any unhandled hash results in MD5 being used
+		{crypto.MD4, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"},
+		{crypto.SHA512, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"},
+	}
+	for _, test := range tests {
+		t.Run(fmt.Sprintf("hash/%v", test.algorithm), func(t *testing.T) {
+			result := hash(test.data, test.algorithm)
+			assertStringEquals(t, test.expected, result)
+		})
+	}
+}
+
+func TestCompare(t *testing.T) {
+	if compare("foo", "bar") != false {
+		t.Error("Expected foo != bar")
+	}
+
+	if compare("foo", "foo") != true {
+		t.Error("Expected foo == foo")
+	}
+}
+
+func TestParseDictHeader(t *testing.T) {
+	var tests = []struct {
+		input    string
+		expected map[string]string
+	}{
+		{"foo=bar", map[string]string{"foo": "bar"}},
+
+		// keys without values get the empty string
+		{"foo", map[string]string{"foo": ""}},
+		{"foo=bar, baz", map[string]string{"foo": "bar", "baz": ""}},
+
+		// no spaces required
+		{"foo=bar,baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
+
+		// spaces are stripped
+		{"foo=bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
+		{"foo= bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
+		{"foo=bar, baz = quux", map[string]string{"foo": "bar", "baz": "quux"}},
+		{" foo =bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
+		{"foo=bar,baz = quux ", map[string]string{"foo": "bar", "baz": "quux"}},
+
+		// quotes around values are stripped
+		{`foo="bar two three four", baz=quux`, map[string]string{"foo": "bar two three four", "baz": "quux"}},
+		{`foo=bar, baz=""`, map[string]string{"foo": "bar", "baz": ""}},
+
+		// quotes around keys are not stripped
+		{`"foo"="bar", "baz two"=quux`, map[string]string{`"foo"`: "bar", `"baz two"`: "quux"}},
+
+		// spaces within quotes around values are preserved
+		{`foo=bar, baz=" quux "`, map[string]string{"foo": "bar", "baz": " quux "}},
+
+		// commas values are NOT handled correctly
+		{`foo="one, two, three", baz=quux`, map[string]string{"foo": `"one`, "two": "", `three"`: "", "baz": "quux"}},
+		{",,,", make(map[string]string)},
+
+		// trailing comma is okay
+		{"foo=bar,", map[string]string{"foo": "bar"}},
+		{"foo=bar,   ", map[string]string{"foo": "bar"}},
+	}
+
+	for _, test := range tests {
+		t.Run(test.input, func(t *testing.T) {
+			results := parseDictHeader(test.input)
+			if !reflect.DeepEqual(test.expected, results) {
+				t.Errorf("expected %#v, got %#v", test.expected, results)
+			}
+		})
+	}
+}
+
+func TestParseAuthorizationHeader(t *testing.T) {
+	var tests = []struct {
+		input    string
+		expected *authorization
+	}{
+		{"", nil},
+		{"Digest", nil},
+		{"Basic QWxhZGRpbjpPcGVuU2VzYW1l", nil},
+
+		// case sensitive on Digest
+		{"digest username=u, realm=r, nonce=n", nil},
+
+		// incomplete headers are fine
+		{"Digest username=u, realm=r, nonce=n", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+			realm:     "r",
+			nonce:     "n",
+		}},
+
+		// algorithm can be either MD5 or SHA-256, with MD5 as default
+		{"Digest username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		{"Digest algorithm=MD5, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		{"Digest algorithm=md5, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		{"Digest algorithm=SHA-256, username=u", &authorization{
+			algorithm: crypto.SHA256,
+			username:  "u",
+		}},
+		{"Digest algorithm=foo, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		{"Digest algorithm=SHA-512, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		// algorithm not case sensitive
+		{"Digest algorithm=sha-256, username=u", &authorization{
+			algorithm: crypto.SHA256,
+			username:  "u",
+		}},
+		// but dash is required in SHA-256 is not recognized
+		{"Digest algorithm=SHA256, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		// session variants not recognized
+		{"Digest algorithm=SHA-256-sess, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+		{"Digest algorithm=MD5-sess, username=u", &authorization{
+			algorithm: crypto.MD5,
+			username:  "u",
+		}},
+
+		{exampleAuthorization, &authorization{
+			algorithm: crypto.MD5,
+			cnonce:    "0a4f113b",
+			nc:        "00000001",
+			nonce:     "dcd98b7102dd2f0e8b11d0f600bfb0c093",
+			opaque:    "5ccc069c403ebaf9f0171e9517f40e41",
+			qop:       "auth",
+			realm:     "testrealm@host.com",
+			response:  "6629fae49393a05397450978507c4ef1",
+			uri:       "/dir/index.html",
+			username:  exampleUsername,
+		}},
+	}
+
+	for i, test := range tests {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			got := parseAuthorizationHeader(test.input)
+			if !reflect.DeepEqual(test.expected, got) {
+				t.Errorf("expected %#v, got %#v", test.expected, got)
+			}
+		})
+	}
+}