diff --git a/httpsso/handler_test.go b/httpsso/handler_test.go
index 1856ec59f5d05ad440eb7211fd03dab354f76509..cccec5f508e45ab8cfb0ebddd3509dbbc3309c11 100644
--- a/httpsso/handler_test.go
+++ b/httpsso/handler_test.go
@@ -41,9 +41,8 @@ const (
 	testLoginServer = "login.example.com"
 )
 
-func makeAuthRequest(t testing.TB, baseUri, path, service, domain string, priv []byte) []byte {
-	c := newTestHTTPClient()
-	resp, err := c.Get(baseUri + path)
+func makeAuthRequest(t testing.TB, c *http.Client, base, path, service, domain string, priv []byte) []byte {
+	resp, err := c.Get(base + path)
 	if err != nil {
 		t.Fatalf("Get(%s): %v", path, err)
 	}
@@ -75,7 +74,7 @@ func makeAuthRequest(t testing.TB, baseUri, path, service, domain string, priv [
 	destURL := "https://" + testHost + "/test"
 	u.Set("d", destURL)
 	u.Set("t", signed)
-	resp, err = c.Get(baseUri + "/sso_login?" + u.Encode())
+	resp, err = c.Get(base + "/sso_login?" + u.Encode())
 	if err != nil {
 		t.Fatal("Get(/sso_login):", err)
 	}
@@ -88,7 +87,7 @@ func makeAuthRequest(t testing.TB, baseUri, path, service, domain string, priv [
 	}
 
 	// Finally, requesting the original URL should work now.
-	resp, err = c.Get(baseUri + path)
+	resp, err = c.Get(base + path)
 	if err != nil {
 		t.Fatalf("Get(%s, post-auth): %v", path, err)
 	}
@@ -123,7 +122,8 @@ func TestSSOWrapper(t *testing.T) {
 	defer srv.Close()
 
 	// Request a sample URL.
-	data := string(makeAuthRequest(t, srv.URL, "/test", testService, testDomain, priv))
+	c := newTestHTTPClient()
+	data := string(makeAuthRequest(t, c, srv.URL, "/test", testService, testDomain, priv))
 	if data != "OK" {
 		t.Fatalf("Get() returned bad data: %s", data)
 	}
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0c45777dd2534d4be9be3d1f0c24e603daf4fc57
--- /dev/null
+++ b/proxy/proxy_test.go
@@ -0,0 +1,154 @@
+package proxy
+
+import (
+	"crypto/rand"
+	"crypto/tls"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"testing"
+	"time"
+
+	sso "git.autistici.org/id/go-sso"
+	"github.com/gorilla/securecookie"
+	"golang.org/x/crypto/ed25519"
+)
+
+func createTestServer(t testing.TB) *httptest.Server {
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		io.WriteString(w, "OK")
+	}))
+}
+
+func TestProxy(t *testing.T) {
+	tmpdir, err := ioutil.TempDir("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tmpdir)
+
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ioutil.WriteFile(tmpdir+"/public.key", pub, 0644)
+
+	targetSrv := createTestServer(t)
+	targetURL, _ := url.Parse(targetSrv.URL)
+	defer targetSrv.Close()
+
+	config := &Config{
+		SessionAuthKey:    string(securecookie.GenerateRandomKey(64)),
+		SessionEncKey:     string(securecookie.GenerateRandomKey(32)),
+		SSOLoginServerURL: "https://login.example.com/",
+		SSOPublicKeyFile:  tmpdir + "/public.key",
+		SSODomain:         "example.com",
+		Backends: []*Backend{
+			&Backend{
+				Host:     "test.example.com",
+				Upstream: []string{targetURL.Host},
+			},
+		},
+	}
+
+	proxy, err := NewProxy(config)
+	if err != nil {
+		t.Fatal(err)
+	}
+	proxySrv := httptest.NewTLSServer(proxy)
+	defer proxySrv.Close()
+
+	c := newTestHTTPClient(proxySrv.URL)
+
+	data := string(makeAuthRequest(t, c, "https://test.example.com", "/", "test.example.com/", "example.com", priv))
+	if data != "OK" {
+		t.Fatalf("bad response: %s", data)
+	}
+}
+
+// Create a http.Client locked to a specific address - no matter the
+// URL, the underlying transport will make a connection to the server
+// specified in uri.
+func newTestHTTPClient(uri string) *http.Client {
+	u, _ := url.Parse(uri)
+	addr := u.Host
+	jar, _ := cookiejar.New(nil)
+	return &http.Client{
+		Jar: jar,
+		Transport: &http.Transport{
+			Dial: func(n, _ string) (net.Conn, error) {
+				return net.Dial(n, addr)
+			},
+			TLSClientConfig: &tls.Config{
+				InsecureSkipVerify: true,
+			},
+		},
+		// This client will not follow redirects.
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+}
+
+func makeAuthRequest(t testing.TB, c *http.Client, base, path, service, domain string, priv []byte) []byte {
+	resp, err := c.Get(base + path)
+	if err != nil {
+		t.Fatalf("Get(%s): %v", path, err)
+	}
+	if resp.StatusCode != http.StatusFound {
+		t.Fatalf("Get(%s) expected 302, got %d", path, resp.StatusCode)
+	}
+	loc, err := url.Parse(resp.Header.Get("Location"))
+	if err != nil {
+		t.Fatalf("Get(%s) redirects to unparsable URL %s: %v", path, resp.Header.Get("Location"), err)
+	}
+	// if loc.Host != testLoginServer {
+	// 	t.Fatalf("Get(%s) got bad redirect: %s", path, loc)
+	// }
+	resp.Body.Close()
+
+	// Sign a ticket, pretending we are the SSO server, then make
+	// a new request to the sso_login endpoint.
+	signer, err := sso.NewSigner(priv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	nonce := loc.Query().Get("n")
+	tkt := sso.NewTicket("user", service, domain, nonce, nil, 300*time.Second)
+	signed, err := signer.Sign(tkt)
+	if err != nil {
+		t.Fatal("Sign():", err)
+	}
+	u := make(url.Values)
+	destURL := base + path
+	u.Set("d", destURL)
+	u.Set("t", signed)
+	resp, err = c.Get(base + "/sso_login?" + u.Encode())
+	if err != nil {
+		t.Fatal("Get(/sso_login):", err)
+	}
+	if resp.StatusCode != http.StatusFound {
+		t.Fatalf("Get(/sso_login) expected 302, got %d", resp.StatusCode)
+	}
+	resp.Body.Close()
+	if s := resp.Header.Get("Location"); s != destURL {
+		t.Fatalf("Get(/sso_login) redirects to unexpected location %s", s)
+	}
+
+	// Finally, requesting the original URL should work now.
+	resp, err = c.Get(base + path)
+	if err != nil {
+		t.Fatalf("Get(%s, post-auth): %v", path, err)
+	}
+	if resp.StatusCode != 200 {
+		t.Fatalf("Get(%s, post-auth) expected 200, got %d", path, resp.StatusCode)
+	}
+	data, _ := ioutil.ReadAll(resp.Body)
+	resp.Body.Close()
+	return data
+}