diff --git a/server/http_test.go b/server/http_test.go
index a9cdf13cd086bf85bdc291b39c2e1687b2768a66..60864b008f05111676ac8fa1c18c68831aa31215 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"io"
 	"io/ioutil"
+	"net"
 	"net/http"
 	"net/http/cookiejar"
 	"net/http/httptest"
@@ -68,12 +69,23 @@ func startTestHTTPServerWithKeyStore(t testing.TB) (string, *httptest.Server) {
 	return tmpdir, createTestHTTPServer(t, config)
 }
 
-func newTestHTTPClient() *http.Client {
+func makeHTTPClient(dnsOverrides map[string]string, followExternalRedirects bool) *http.Client {
 	jar, _ := cookiejar.New(nil)
+	var dialfn func(ctx context.Context, network, addr string) (net.Conn, error)
+	if dnsOverrides != nil {
+		dialer := new(net.Dialer)
+		dialfn = func(ctx context.Context, network, addr string) (net.Conn, error) {
+			if override, ok := dnsOverrides[addr]; ok {
+				addr = override
+			}
+			return dialer.DialContext(ctx, network, addr)
+		}
+	}
 	transport := NewLoggedTransport(&http.Transport{
 		TLSClientConfig: &tls.Config{
 			InsecureSkipVerify: true,
 		},
+		DialContext: dialfn,
 	}, DefaultLogger{Dump: true})
 	return &http.Client{
 		Jar:       jar,
@@ -83,7 +95,7 @@ func newTestHTTPClient() *http.Client {
 			if len(via) > 10 {
 				return errors.New("too many redirects")
 			}
-			if !strings.HasPrefix(req.URL.Host, "127.0.0.1:") {
+			if !followExternalRedirects && !strings.HasPrefix(req.URL.Host, "127.0.0.1:") {
 				return http.ErrUseLastResponse
 			}
 			return nil
@@ -91,6 +103,10 @@ func newTestHTTPClient() *http.Client {
 	}
 }
 
+func newTestHTTPClient() *http.Client {
+	return makeHTTPClient(nil, false)
+}
+
 func TestHTTP_ServeStaticAsset(t *testing.T) {
 	tmpdir, httpSrv := startTestHTTPServer(t)
 	defer os.RemoveAll(tmpdir)
@@ -106,10 +122,10 @@ func TestHTTP_ServeStaticAsset(t *testing.T) {
 	}
 }
 
-func doGet(t testing.TB, srv *httptest.Server, c *http.Client, relativeURL string, checkResponse ...func(testing.TB, *http.Response)) {
-	resp, err := c.Get(srv.URL + relativeURL)
+func doGet(t testing.TB, c *http.Client, uri string, checkResponse ...func(testing.TB, *http.Response)) {
+	resp, err := c.Get(uri)
 	if err != nil {
-		t.Fatalf("http.Get(%s): %v", relativeURL, err)
+		t.Fatalf("http.Get(%s): %v", uri, err)
 	}
 	defer resp.Body.Close()
 	for _, f := range checkResponse {
@@ -117,10 +133,10 @@ func doGet(t testing.TB, srv *httptest.Server, c *http.Client, relativeURL strin
 	}
 }
 
-func doPostForm(t testing.TB, srv *httptest.Server, c *http.Client, relativeURL string, v url.Values, checkResponse ...func(testing.TB, *http.Response)) {
-	resp, err := c.PostForm(srv.URL+relativeURL, v)
+func doPostForm(t testing.TB, c *http.Client, uri string, v url.Values, checkResponse ...func(testing.TB, *http.Response)) {
+	resp, err := c.PostForm(uri, v)
 	if err != nil {
-		t.Fatalf("http.Get(%s): %v", relativeURL, err)
+		t.Fatalf("http.Get(%s): %v", uri, err)
 	}
 	defer resp.Body.Close()
 	for _, f := range checkResponse {
@@ -173,6 +189,21 @@ func checkLoginOTPPage(t testing.TB, resp *http.Response) {
 	}
 }
 
+func checkLogoutPage(t testing.TB, resp *http.Response) {
+	if resp.Request.URL.Path != "/logout" {
+		t.Errorf("request path is not /logout (%s)", resp.Request.URL.String())
+	}
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("reading body: %v", err)
+	}
+
+	if s := string(data); !strings.Contains(s, "Signing you out from all services") {
+		t.Fatalf("not the logout page:\n%s", s)
+	}
+}
+
 func TestHTTP_Login(t *testing.T) {
 	tmpdir, httpSrv := startTestHTTPServer(t)
 	defer os.RemoveAll(tmpdir)
@@ -186,14 +217,14 @@ func TestHTTP_Login(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We expect the
 	// result to be a 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkRedirectToTargetService)
 }
 
 func TestHTTP_LoginOnSecondAttempt(t *testing.T) {
@@ -209,20 +240,20 @@ func TestHTTP_LoginOnSecondAttempt(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login with wrong credentials.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "badpassword")
-	doPostForm(t, httpSrv, c, "/login", v, checkStatusOk, checkLoginPasswordPage)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We expect the
 	// result to be a 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkRedirectToTargetService)
 }
 
 func TestHTTP_LoginAndLogout(t *testing.T) {
@@ -238,24 +269,24 @@ func TestHTTP_LoginAndLogout(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We expect the
 	// result to be a 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkRedirectToTargetService)
 
 	// Make a logout request.
-	doGet(t, httpSrv, c, "/logout", checkStatusOk)
+	doGet(t, c, httpSrv.URL+"/logout", checkStatusOk, checkLogoutPage)
 
 	// This new authorization request should send us to the login page.
 	v = make(url.Values)
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 }
 
 func TestHTTP_LoginOTP(t *testing.T) {
@@ -271,19 +302,19 @@ func TestHTTP_LoginOTP(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We should see the OTP page.
 	v = make(url.Values)
 	v.Set("username", "test2fa")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkStatusOk, checkLoginOTPPage)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkStatusOk, checkLoginOTPPage)
 
 	// Submit the correct OTP token. We expect the result to be a
 	// 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("otp", "123456")
-	doPostForm(t, httpSrv, c, "/login/otp", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login/otp", v, checkRedirectToTargetService)
 }
 
 func createFakeKeyStore(t testing.TB, username, password string) *httptest.Server {
@@ -322,14 +353,14 @@ func TestHTTP_LoginWithKeyStore(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We expect the
 	// result to be a 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkRedirectToTargetService)
 }
 
 func TestHTTP_CORS(t *testing.T) {
@@ -346,14 +377,14 @@ func TestHTTP_CORS(t *testing.T) {
 	v.Set("s", "service.example.com/")
 	v.Set("d", "https://service.example.com/admin/")
 	v.Set("n", "averysecretnonce")
-	doGet(t, httpSrv, c, "/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
+	doGet(t, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPasswordPage)
 
 	// Attempt to login by submitting the form. We expect the
 	// result to be a 302 redirect to the target service.
 	v = make(url.Values)
 	v.Set("username", "testuser")
 	v.Set("password", "password")
-	doPostForm(t, httpSrv, c, "/login", v, checkRedirectToTargetService)
+	doPostForm(t, c, httpSrv.URL+"/login", v, checkRedirectToTargetService)
 
 	// Simulate a CORS preflight request.
 	v = make(url.Values)
diff --git a/server/integration_test.go b/server/integration_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..930b62afb3862e1612af601c8430fbfd3519be95
--- /dev/null
+++ b/server/integration_test.go
@@ -0,0 +1,105 @@
+package server
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.autistici.org/id/go-sso/httpsso"
+	"github.com/gorilla/securecookie"
+)
+
+// Create a SSO-wrapped service.
+func createTestProtectedService(t testing.TB, serverURL, tmpdir string) *httptest.Server {
+	ssoPubKey, err := ioutil.ReadFile(filepath.Join(tmpdir, "public"))
+	if err != nil {
+		t.Fatalf("oops, can't read sso public key: %v", err)
+	}
+
+	w, err := httpsso.NewSSOWrapper(
+		serverURL,
+		ssoPubKey,
+		"example.com",
+		securecookie.GenerateRandomKey(64),
+		securecookie.GenerateRandomKey(32),
+		0,
+	)
+	if err != nil {
+		t.Fatalf("NewSSOWrapper(): %v", err)
+	}
+
+	h := w.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Write([]byte("OK")) // nolint
+	}), "service.example.com/", nil)
+
+	return httptest.NewTLSServer(h)
+}
+
+func startTestHTTPServerAndApp(t testing.TB) (string, *httptest.Server, *httptest.Server) {
+	tmpdir, _ := ioutil.TempDir("", "")
+	config := testConfig(t, tmpdir, "")
+	srv := createTestHTTPServer(t, config)
+	app := createTestProtectedService(t, "https://login.example.com/", tmpdir)
+	return tmpdir, srv, app
+}
+
+func checkIsProtectedService(t testing.TB, resp *http.Response) {
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("reading body: %v", err)
+	}
+	if s := string(data); s != "OK" {
+		t.Fatalf("not the target application, response body='%s'", s)
+	}
+}
+
+func checkLogoutPageHasLinks(t testing.TB, resp *http.Response) {
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("reading body: %v", err)
+	}
+	logoutURL := "https://service.example.com/sso_logout"
+	if sdata := string(data); !strings.Contains(sdata, logoutURL) {
+		t.Fatalf("service logout URL not found in logout page:\n%s", sdata)
+	}
+}
+
+func addrFromURL(s string) string {
+	u, _ := url.Parse(s)
+	if !strings.Contains(u.Host, ":") {
+		return u.Host + ":443"
+	}
+	return u.Host
+}
+
+// The integration test spins up an actual service and verifies the
+// interaction between it and the login application, using DNS-level
+// overrides to ensure proper name validation.
+func TestIntegration(t *testing.T) {
+	tmpdir, srv, app := startTestHTTPServerAndApp(t)
+	defer os.RemoveAll(tmpdir)
+	defer srv.Close()
+	defer app.Close()
+
+	c := makeHTTPClient(map[string]string{
+		"login.example.com:443":   addrFromURL(srv.URL),
+		"service.example.com:443": addrFromURL(app.URL),
+	}, true)
+
+	doGet(t, c, "https://service.example.com/", checkStatusOk, checkLoginPasswordPage)
+
+	v := make(url.Values)
+	v.Set("username", "testuser")
+	v.Set("password", "password")
+	doPostForm(t, c, "https://login.example.com/login", v, checkStatusOk, checkIsProtectedService)
+
+	// Now attempt to logout, and verify that we can't access the service anymore.
+	doGet(t, c, "https://login.example.com/logout", checkStatusOk, checkLogoutPageHasLinks)
+	doGet(t, c, "https://service.example.com/sso_logout", checkStatusOk)
+	doGet(t, c, "https://service.example.com/", checkStatusOk, checkLoginPasswordPage)
+}