diff --git a/server/bindata.go b/server/bindata.go
index 2ca4c909864581893a97908855c6deb84cec865e..ab3b88704960ea28a73f07f8605a93e4b433cd58 100644
--- a/server/bindata.go
+++ b/server/bindata.go
@@ -150,7 +150,7 @@ func staticCssSigninCss() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "static/css/signin.css", size: 1071, mode: os.FileMode(420), modTime: time.Unix(1576745577, 0)}
+	info := bindataFileInfo{name: "static/css/signin.css", size: 1071, mode: os.FileMode(420), modTime: time.Unix(1576748467, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -1157,7 +1157,7 @@ func templatesLogin_otpHtml() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "templates/login_otp.html", size: 973, mode: os.FileMode(420), modTime: time.Unix(1576746408, 0)}
+	info := bindataFileInfo{name: "templates/login_otp.html", size: 973, mode: os.FileMode(420), modTime: time.Unix(1576748467, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -1285,7 +1285,7 @@ func templatesLogin_u2fHtml() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "templates/login_u2f.html", size: 925, mode: os.FileMode(420), modTime: time.Unix(1576746452, 0)}
+	info := bindataFileInfo{name: "templates/login_u2f.html", size: 925, mode: os.FileMode(420), modTime: time.Unix(1576748467, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -1394,7 +1394,7 @@ func templatesPageHtml() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "templates/page.html", size: 1476, mode: os.FileMode(420), modTime: time.Unix(1576745577, 0)}
+	info := bindataFileInfo{name: "templates/page.html", size: 1476, mode: os.FileMode(420), modTime: time.Unix(1576748467, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
diff --git a/server/http_test.go b/server/http_test.go
index 8d48948881ef5de7123a3d6b0df3a532f69715b4..b3a5e267f8ea748282089febbce4ebde411477a7 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -233,6 +233,18 @@ func checkLoginOTPPage(t testing.TB, resp *http.Response) {
 	}
 }
 
+var authFailureRx = regexp.MustCompile(`<p\s*class="error">\s*Authentication failed`)
+
+func checkAuthFailure(t testing.TB, resp *http.Response) {
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("reading body: %v", err)
+	}
+	if !authFailureRx.Match(data) {
+		t.Fatalf("expected authentication failure, but no errors found:\n%s", string(data))
+	}
+}
+
 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())
@@ -362,6 +374,33 @@ func TestHTTP_LoginOTP(t *testing.T) {
 	doPostForm(t, c, httpSrv.URL+"/login/otp", v, checkRedirectToTargetService)
 }
 
+func TestHTTP_LoginOTP_Fail(t *testing.T) {
+	tmpdir, httpSrv := startTestHTTPServer(t)
+	defer os.RemoveAll(tmpdir)
+	defer httpSrv.Close()
+
+	c := newTestHTTPClient()
+
+	// Simulate an authorization request from a service, expect to
+	// see 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, c, httpSrv.URL+"/?"+v.Encode(), checkStatusOk, checkLoginPageURL, 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, c, httpSrv.URL+"/login", v, checkStatusOk, checkLoginOTPPage)
+
+	// Submit a bad OTP token, test for failure.
+	v = make(url.Values)
+	v.Set("otp", "000000")
+	doPostForm(t, c, httpSrv.URL+"/login/otp", v, checkAuthFailure)
+}
+
 func TestHTTP_LoginOTP_Intermediate404(t *testing.T) {
 	// This test verifies that the session is not disrupted by a
 	// request for a URL that does not exist during a 2FA login