Repository: syncthing/syncthing (language: go)

Issue:
Use a real login screen + sessions instead of HTTP basic auth
I would like to suggest to change the http login prompt with a form, so that I can use a password manager. 

I'm using 1Password which can't fill in login prompts.


Discussion / hints:
You can bookmark urls with the username and password embedded. 
That leaves the password in unprotected storage though.
If this gets implemented, it would be rather nice if it were optional.  The current basic authentication makes it really easy to punt authentication upstream when proxying Syncthing, which is particularly useful in some cases.
You can use the API key to authenticate, if that also fits your use-case.
Mmm. But I think we would still have to accept basic auth credentials for compatibility reasons. Possibly, in the long run, limiting it to localhost-bound or https.
Please refrain from "me too" comments as they add no value. You can use tye emote icons on the original post if you can't contain yourself.
What's the status of this feature request at the moment?
It would be really good to provide form-based login as well as HTTP basic auth, similar to how apps like sonarr/radarr allow this.
It's the same status as it wad 2 years ago, which is "sure, but we're not bothered about it, if you are, open PRs"
As a workaround, we can use a web extension that has partially solved this problem.  Bitwarden claims it can log automatically given you have only account on your localhost domain: https://github.com/bitwarden/clients/issues/116#issuecomment-327991012

I couldn't get it to work exactly as advertised, but it still lets me copy and paste the password from the extension without blocking the UI.

Other extensions exist, but I haven't tested them (e.g., passman and passifox).

Fix patch:
diff --git a/gui/default/index.html b/gui/default/index.html
index 07ac71ddf70..0266da1a03a 100644
--- a/gui/default/index.html
+++ b/gui/default/index.html
@@ -72,7 +72,7 @@ <h3 class="panel-title">
           <img class="logo hidden-xs" src="assets/img/logo-horizontal.svg" height="32" width="117" alt=""/>
           <img class="logo hidden visible-xs" src="assets/img/favicon-default.png" height="32" alt=""/>
         </span>
-        <p class="navbar-text hidden-xs" ng-class="{'hidden-sm':upgradeInfo && upgradeInfo.newer}">{{thisDeviceName()}}</p>
+        <p ng-if="authenticated" class="navbar-text hidden-xs" ng-class="{'hidden-sm':upgradeInfo && upgradeInfo.newer}">{{thisDeviceName()}}</p>
         <ul class="nav navbar-nav navbar-right">
           <li ng-if="upgradeInfo && upgradeInfo.newer" class="upgrade-newer">
             <button type="button" class="btn navbar-btn btn-primary btn-sm" data-toggle="modal" data-target="#upgrade">
@@ -109,21 +109,25 @@ <h3 class="panel-title">
               <li><a href="" ng-click="about.show()"><span class="fa fa-fw fa-heart"></span>&nbsp;<span translate>About</span></a></li>
             </ul>
           </li>
-          <li class="dropdown action-menu">
+          <li ng-if="authenticated || config.gui.debugging" class="dropdown action-menu">
             <a href="#" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
               <span class="fa fa-cog"></span>
               <span class="hidden-xs" translate>Actions</span>
               <span class="caret"></span>
             </a>
             <ul class="dropdown-menu">
-              <li><a href="" ng-click="showSettings()"><span class="fa fa-fw fa-cog"></span>&nbsp;<span translate>Settings</span></a></li>
-              <li><a href="" ng-click="showDeviceIdentification(thisDevice())"><span class="fa fa-fw fa-qrcode"></span>&nbsp;<span translate>Show ID</span></a></li>
-              <li class="divider" aria-hidden="true"></li>
-              <li><a href="" ng-click="shutdown()"><span class="fa fa-fw fa-power-off"></span>&nbsp;<span translate>Shutdown</span></a></li>
-              <li><a href="" ng-click="restart()"><span class="fa fa-fw fa-refresh"></span>&nbsp;<span translate>Restart</span></a></li>
-              <li class="divider" aria-hidden="true"></li>
-              <li><a href="" ng-click="advanced()"><span class="fa fa-fw fa-cogs"></span>&nbsp;<span translate>Advanced</span></a></li>
-              <li><a href="" ng-click="logging.show()"><span class="fa fa-fw fa-wrench"></span>&nbsp;<span translate>Logs</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="showSettings()"><span class="fa fa-fw fa-cog"></span>&nbsp;<span translate>Settings</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="showDeviceIdentification(thisDevice())"><span class="fa fa-fw fa-qrcode"></span>&nbsp;<span translate>Show ID</span></a></li>
+
+              <li ng-if="authenticated" class="divider" aria-hidden="true"></li>
+              <li ng-if="authenticated"><a href="" ng-click="shutdown()"><span class="fa fa-fw fa-power-off"></span>&nbsp;<span translate>Shutdown</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="restart()"><span class="fa fa-fw fa-refresh"></span>&nbsp;<span translate>Restart</span></a></li>
+
+              <li ng-if="authenticated" class="divider" aria-hidden="true"></li>
+              <li ng-if="authenticated"><a href="" ng-click="advanced()"><span class="fa fa-fw fa-cogs"></span>&nbsp;<span translate>Advanced</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="logging.show()"><span class="fa fa-fw fa-wrench"></span>&nbsp;<span translate>Logs</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="logout()"><span class="far fa-fw fa-ban"></span>&nbsp;<span translate>Log Out</span></a></li>
+
               <li class="divider" aria-hidden="true" ng-if="config.gui.debugging"></li>
               <li><a href="/rest/debug/support" target="_blank" ng-if="config.gui.debugging"><span class="fa fa-fw fa-user-md"></span>&nbsp;<span translate>Support Bundle</span></a></li>
             </ul>
@@ -338,9 +342,39 @@ <h3 class="panel-title">
         </div>
       </div>
 
-      <!-- First regular row -->
+      <!-- Login form -->
+      <div ng-if="!authenticated" class="center-block">
+        <h3 translate>Authentication Required</h3>
 
-      <div class="row">
+        <form ng-submit="authenticatePassword()">
+          <div class="form-group">
+            <label for="user" translate>User</label>
+            <input id="user" class="form-control" type="text" name="user" ng-model="login.username" autofocus required autocomplete="username" />
+          </div>
+
+          <div class="form-group">
+            <label for="password" translate>Password</label>
+            <input id="password" class="form-control" type="password" name="password" ng-model="login.password" ng-trim="false" autocomplete="current-password" />
+          </div>
+
+          <div class="row">
+            <div class="col-md-9 login-form-messages">
+              <p ng-if="login.errors.badLogin" class="text-danger" translate>
+                Incorrect user name or password.
+              </p>
+              <p ng-if="login.errors.failed" class="text-danger" translate>
+                Login failed, see Syncthing logs for details.
+              </p>
+            </div>
+            <div class="col-md-3 text-right">
+              <button type="submit" class="btn btn-default" ng-disabled="login.inProgress" translate>Log In</button>
+            </div>
+          </div>
+        </form>
+      </div>
+
+      <!-- First regular row -->
+      <div ng-if="authenticated" class="row">
 
         <!-- Folder list (top left) -->
 
diff --git a/gui/default/syncthing/app.js b/gui/default/syncthing/app.js
index 94b4a5f619a..6c85be49956 100644
--- a/gui/default/syncthing/app.js
+++ b/gui/default/syncthing/app.js
@@ -16,13 +16,9 @@ var syncthing = angular.module('syncthing', [
 ]);
 
 var urlbase = 'rest';
+var authUrlbase = urlbase + '/noauth/auth';
 
 syncthing.config(function ($httpProvider, $translateProvider, LocaleServiceProvider) {
-    var deviceIDShort = metadata.deviceID.substr(0, 5);
-    $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + deviceIDShort;
-    $httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + deviceIDShort;
-    $httpProvider.useApplyAsync(true);
-
     // language and localisation
 
     $translateProvider.useSanitizeValueStrategy('escape');
@@ -33,6 +29,18 @@ syncthing.config(function ($httpProvider, $translateProvider, LocaleServiceProvi
 
     LocaleServiceProvider.setAvailableLocales(validLangs);
     LocaleServiceProvider.setDefaultLocale('en');
+
+    $httpProvider.useApplyAsync(true);
+
+    if (!window.metadata) {
+        // Most likely we're not authenticated yet, in which case we can't proceed with the rest of the setup.
+        // Do nothing and wait for the page reload on successful login.
+        return;
+    }
+
+    var deviceIDShort = metadata.deviceID.substr(0, 5);
+    $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + deviceIDShort;
+    $httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + deviceIDShort;
 });
 
 // @TODO: extract global level functions into separate service(s)
diff --git a/gui/default/syncthing/core/aboutModalView.html b/gui/default/syncthing/core/aboutModalView.html
index 6715ee412a0..cc52401ef2f 100644
--- a/gui/default/syncthing/core/aboutModalView.html
+++ b/gui/default/syncthing/core/aboutModalView.html
@@ -2,17 +2,21 @@
   <div class="modal-body">
     <h1 class="text-center">
       <img alt="Syncthing" src="assets/img/logo-horizontal.svg" style="max-width: 366px; vertical-align: -16px" />
-      <br />
+    </h1>
+    <h1 ng-if="version.version" class="text-center">
       <small>{{versionString()}}</small>
       <br />
       <small><i>"{{version.codename}}"</i></small>
     </h1>
-    <p class="text-center">
+    <p ng-if="version.version" class="text-center">
       Build {{version.date | date:"yyyy-MM-dd"}}
       <span ng-if="version.tags.length">({{version.tags.join(", ")}})</span>
       <br />
       Copyright &copy; 2014-{{version.date | date:"yyyy"}} the Syncthing Authors.
     </p>
+    <p ng-if="!version.version" class="text-center" translate>
+      Log in to see version information.
+    </p>
     <p class="text-center" translate>Syncthing is Free and Open Source Software licensed as MPL v2.0.</p>
 
     <ul class="nav nav-tabs">
@@ -81,7 +85,10 @@ <h4 class="text-center" translate>The Syncthing Authors</h4>
       </div>
 
       <div id="about-paths" class="tab-pane">
-        <table class="table table-striped table-auto">
+        <p ng-if="!authenticated" translate>
+          Log in to see paths information.
+        </p>
+        <table ng-if="authenticated" class="table table-striped table-auto">
           <caption><label translate>Internally used paths:</label></caption>
           <tbody>
             <tr>
diff --git a/gui/default/syncthing/core/eventService.js b/gui/default/syncthing/core/eventService.js
index cb37b652064..747fa7b5233 100644
--- a/gui/default/syncthing/core/eventService.js
+++ b/gui/default/syncthing/core/eventService.js
@@ -38,7 +38,13 @@ angular.module('syncthing.core')
                 .error(errorFn);
         }
 
-        function errorFn(dummy) {
+        function errorFn(statusString, status) {
+            if (status === 403) {
+                // Auth error - reload login page
+                location.reload();
+                return;
+            }
+
             $rootScope.$broadcast(self.OFFLINE);
 
             $timeout(function () {
diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js
index e92b0ab66e6..492e877c222 100755
--- a/gui/default/syncthing/core/syncthingController.js
+++ b/gui/default/syncthing/core/syncthingController.js
@@ -14,12 +14,26 @@ angular.module('syncthing.core')
 
         function initController() {
             LocaleService.autoConfigLocale();
+
+            if (!$scope.authenticated) {
+                // Can't proceed yet - wait for the page reload after successful login.
+                return;
+            }
+
             setInterval($scope.refresh, 10000);
             Events.start();
         }
 
         // public/scope definitions
 
+        // window.metadata is set in /meta.js which requires authentication
+        $scope.authenticated = window.metadata && window.metadata.authenticated;
+
+        $scope.login = {
+            username: '',
+            password: '',
+            errors: {},
+        };
         $scope.completion = {};
         $scope.config = {};
         $scope.configInSync = true;
@@ -83,6 +97,35 @@ angular.module('syncthing.core')
             files: 0
         };
 
+        $scope.authenticatePassword = function () {
+            $scope.login.inProgress = true;
+            $scope.login.errors = {};
+            $http.post(authUrlbase + '/password', {
+              username: $scope.login.username,
+              password: $scope.login.password,
+            }).then(function () {
+                location.reload();
+            }).catch(function (response) {
+                if (response.status === 403) {
+                    $scope.login.errors.badLogin = true;
+                } else {
+                    $scope.login.errors.failed = true;
+                    console.log('Password authentication failed:', response);
+                }
+            }).finally(function () {
+                $scope.login.inProgress = false;
+            });
+        };
+
+        $scope.logout = function() {
+            $http.post(authUrlbase + '/logout', {})
+            .then(function () {
+                location.reload();
+            }).catch(function (response) {
+                console.log('Failed to log out:', response);
+            });
+        };
+
         $(window).bind('beforeunload', function () {
             navigatingAway = true;
         });
@@ -183,6 +226,9 @@ angular.module('syncthing.core')
                 if (arg.status === 0) {
                     // A network error, not an HTTP error
                     $scope.$emit(Events.OFFLINE);
+                } else if (arg.status === 403) {
+                    // Auth error - reload login page
+                    location.reload();
                 } else if (arg.status >= 400 && arg.status <= 599 && arg.status != 501) {
                     // A genuine HTTP error. 501/NotImplemented is considered intentional
                     // and not an error which we need to act upon.
@@ -3119,6 +3165,9 @@ angular.module('syncthing.core')
 
         $scope.docsURL = function (path) {
             var url = 'https://docs.syncthing.net';
+            if (!$scope.versionBase()) {
+                return url;
+            }
             if (!path) {
                 // Undefined or null should become a valid string.
                 path = '';
diff --git a/gui/default/syncthing/settings/settingsModalView.html b/gui/default/syncthing/settings/settingsModalView.html
index dc2f3fd7c27..a5b28f97ec9 100644
--- a/gui/default/syncthing/settings/settingsModalView.html
+++ b/gui/default/syncthing/settings/settingsModalView.html
@@ -128,14 +128,14 @@
           <div class="row">
             <div class="col-md-6">
               <div class="form-group">
-                <label translate for="User">GUI Authentication User</label>
-                <input id="User" class="form-control" type="text" ng-model="tmpGUI.user" autocomplete="off" />
+                <label translate for="user">GUI Authentication User</label>
+                <input id="user" class="form-control" type="text" name="user" ng-model="tmpGUI.user" autocomplete="username" />
               </div>
             </div>
             <div class="col-md-6">
               <div class="form-group">
-                <label translate for="Password">GUI Authentication Password</label>
-                <input id="Password" class="form-control" type="password" ng-model="tmpGUI.password" ng-trim="false" autocomplete="new-password" />
+                <label translate for="password">GUI Authentication Password</label>
+                <input id="password" class="form-control" type="password" name="password" ng-model="tmpGUI.password" ng-trim="false" autocomplete="new-password" />
               </div>
             </div>
           </div>
diff --git a/lib/api/api.go b/lib/api/api.go
index 67ebf0fcee5..1f2fd3a5546 100644
--- a/lib/api/api.go
+++ b/lib/api/api.go
@@ -350,7 +350,7 @@ func (s *service) Serve(ctx context.Context) error {
 	mux.Handle("/", s.statics)
 
 	// Handle the special meta.js path
-	mux.HandleFunc("/meta.js", s.getJSMetadata)
+	mux.Handle("/meta.js", noCacheMiddleware(http.HandlerFunc(s.getJSMetadata)))
 
 	// Handle Prometheus metrics
 	promHttpHandler := promhttp.Handler()
@@ -372,7 +372,13 @@ func (s *service) Serve(ctx context.Context) error {
 
 	// Wrap everything in basic auth, if user/password is set.
 	if guiCfg.IsAuthEnabled() {
-		handler = basicAuthAndSessionMiddleware("sessionid-"+s.id.String()[:5], guiCfg, s.cfg.LDAP(), handler, s.evLogger)
+		sessionCookieName := "sessionid-" + s.id.String()[:5]
+		handler = basicAuthAndSessionMiddleware(sessionCookieName, guiCfg, s.cfg.LDAP(), handler, s.evLogger)
+		handlePasswordAuth := passwordAuthHandler(sessionCookieName, guiCfg, s.cfg.LDAP(), s.evLogger)
+		restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", handlePasswordAuth)
+
+		// Logout is a no-op without a valid session cookie, so /noauth/ is fine here
+		restMux.Handler(http.MethodPost, "/rest/noauth/auth/logout", handleLogout(sessionCookieName))
 	}
 
 	// Redirect to HTTPS if we are supposed to
@@ -711,8 +717,9 @@ func (*service) getSystemPaths(w http.ResponseWriter, _ *http.Request) {
 }
 
 func (s *service) getJSMetadata(w http.ResponseWriter, _ *http.Request) {
-	meta, _ := json.Marshal(map[string]string{
-		"deviceID": s.id.String(),
+	meta, _ := json.Marshal(map[string]interface{}{
+		"deviceID":      s.id.String(),
+		"authenticated": true,
 	})
 	w.Header().Set("Content-Type", "application/javascript")
 	fmt.Fprintf(w, "var metadata = %s;\n", meta)
diff --git a/lib/api/api_auth.go b/lib/api/api_auth.go
index 58a97e55823..73560a60d03 100644
--- a/lib/api/api_auth.go
+++ b/lib/api/api_auth.go
@@ -19,6 +19,7 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/sync"
+	"golang.org/x/exp/slices"
 )
 
 var (
@@ -37,6 +38,48 @@ func emitLoginAttempt(success bool, username, address string, evLogger events.Lo
 	}
 }
 
+func antiBruteForceSleep() {
+	time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
+}
+
+func unauthorized(w http.ResponseWriter) {
+	antiBruteForceSleep()
+	w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
+	http.Error(w, "Not Authorized", http.StatusUnauthorized)
+}
+
+func forbidden(w http.ResponseWriter) {
+	antiBruteForceSleep()
+	http.Error(w, "Forbidden", http.StatusForbidden)
+}
+
+func isNoAuthPath(path string) bool {
+	// Local variable instead of module var to prevent accidental mutation
+	noAuthPaths := []string{
+		"/",
+		"/index.html",
+		"/modal.html",
+		"/rest/svc/lang", // Required to load language settings on login page
+	}
+
+	// Local variable instead of module var to prevent accidental mutation
+	noAuthPrefixes := []string{
+		// Static assets
+		"/assets/",
+		"/syncthing/",
+		"/vendor/",
+		"/theme-assets/", // This leaks information from config, but probably not sensitive
+
+		// No-auth API endpoints
+		"/rest/noauth",
+	}
+
+	return slices.Contains(noAuthPaths, path) ||
+		slices.ContainsFunc(noAuthPrefixes, func(prefix string) bool {
+			return strings.HasPrefix(path, prefix)
+		})
+}
+
 func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if hasValidAPIKeyHeader(r, guiCfg) {
@@ -44,8 +87,8 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
 			return
 		}
 
-		// Exception for REST calls that don't require authentication.
-		if strings.HasPrefix(r.URL.Path, "/rest/noauth") {
+		// Exception for static assets and REST calls that don't require authentication.
+		if isNoAuthPath(r.URL.Path) {
 			next.ServeHTTP(w, r)
 			return
 		}
@@ -61,60 +104,116 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
 			}
 		}
 
-		l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
-
-		error := func() {
-			time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
-			w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
-			http.Error(w, "Not Authorized", http.StatusUnauthorized)
+		// Fall back to Basic auth if provided
+		if username, ok := attemptBasicAuth(r, guiCfg, ldapCfg, evLogger); ok {
+			createSession(cookieName, username, guiCfg, evLogger, w, r)
+			next.ServeHTTP(w, r)
+			return
 		}
 
-		username, password, ok := r.BasicAuth()
-		if !ok {
-			error()
+		// Some browsers don't send the Authorization request header unless prompted by a 401 response.
+		// This enables https://user:pass@localhost style URLs to keep working.
+		if guiCfg.SendBasicAuthPrompt {
+			unauthorized(w)
 			return
 		}
 
-		authOk := auth(username, password, guiCfg, ldapCfg)
-		if !authOk {
-			usernameIso := string(iso88591ToUTF8([]byte(username)))
-			passwordIso := string(iso88591ToUTF8([]byte(password)))
-			authOk = auth(usernameIso, passwordIso, guiCfg, ldapCfg)
-			if authOk {
-				username = usernameIso
-			}
+		forbidden(w)
+	})
+}
+
+func passwordAuthHandler(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var req struct {
+			Username string
+			Password string
+		}
+		if err := unmarshalTo(r.Body, &req); err != nil {
+			l.Debugln("Failed to parse username and password:", err)
+			http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
+			return
 		}
 
-		if !authOk {
-			emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
-			error()
+		if auth(req.Username, req.Password, guiCfg, ldapCfg) {
+			createSession(cookieName, req.Username, guiCfg, evLogger, w, r)
+			w.WriteHeader(http.StatusNoContent)
 			return
 		}
 
-		sessionid := rand.String(32)
-		sessionsMut.Lock()
-		sessions[sessionid] = true
-		sessionsMut.Unlock()
-
-		// Best effort detection of whether the connection is HTTPS --
-		// either directly to us, or as used by the client towards a reverse
-		// proxy who sends us headers.
-		connectionIsHTTPS := r.TLS != nil ||
-			strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
-			strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
-		// If the connection is HTTPS, or *should* be HTTPS, set the Secure
-		// bit in cookies.
-		useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
+		emitLoginAttempt(false, req.Username, r.RemoteAddr, evLogger)
+		forbidden(w)
+	})
+}
+
+func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) (string, bool) {
+	username, password, ok := r.BasicAuth()
+	if !ok {
+		return "", false
+	}
+
+	l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
+
+	if auth(username, password, guiCfg, ldapCfg) {
+		return username, true
+	}
+
+	usernameFromIso := string(iso88591ToUTF8([]byte(username)))
+	passwordFromIso := string(iso88591ToUTF8([]byte(password)))
+	if auth(usernameFromIso, passwordFromIso, guiCfg, ldapCfg) {
+		return usernameFromIso, true
+	}
+
+	emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
+	return "", false
+}
+
+func createSession(cookieName string, username string, guiCfg config.GUIConfiguration, evLogger events.Logger, w http.ResponseWriter, r *http.Request) {
+	sessionid := rand.String(32)
+	sessionsMut.Lock()
+	sessions[sessionid] = true
+	sessionsMut.Unlock()
+
+	// Best effort detection of whether the connection is HTTPS --
+	// either directly to us, or as used by the client towards a reverse
+	// proxy who sends us headers.
+	connectionIsHTTPS := r.TLS != nil ||
+		strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
+		strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
+	// If the connection is HTTPS, or *should* be HTTPS, set the Secure
+	// bit in cookies.
+	useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
+
+	http.SetCookie(w, &http.Cookie{
+		Name:  cookieName,
+		Value: sessionid,
+		// In HTTP spec Max-Age <= 0 means delete immediately,
+		// but in http.Cookie MaxAge = 0 means unspecified (session) and MaxAge < 0 means delete immediately
+		MaxAge: 0,
+		Secure: useSecureCookie,
+		Path:   "/",
+	})
+
+	emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
+}
+
+func handleLogout(cookieName string) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		cookie, err := r.Cookie(cookieName)
+		if err == nil && cookie != nil {
+			sessionsMut.Lock()
+			delete(sessions, cookie.Value)
+			sessionsMut.Unlock()
+		}
+		// else: If there is no session cookie, that's also a successful logout in terms of user experience.
 
 		http.SetCookie(w, &http.Cookie{
 			Name:   cookieName,
-			Value:  sessionid,
-			MaxAge: 0,
-			Secure: useSecureCookie,
+			Value:  "",
+			MaxAge: -1,
+			Secure: true,
+			Path:   "/",
 		})
-
-		emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
-		next.ServeHTTP(w, r)
+		w.WriteHeader(http.StatusNoContent)
 	})
 }
 
diff --git a/lib/api/api_csrf.go b/lib/api/api_csrf.go
index 25e613a4215..2a309a4e8ee 100644
--- a/lib/api/api_csrf.go
+++ b/lib/api/api_csrf.go
@@ -74,13 +74,6 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if strings.HasPrefix(r.URL.Path, "/rest/noauth") {
-		// REST calls that don't require authentication also do not
-		// need a CSRF token.
-		m.next.ServeHTTP(w, r)
-		return
-	}
-
 	// Allow requests for anything not under the protected path prefix,
 	// and set a CSRF cookie if there isn't already a valid one.
 	if !strings.HasPrefix(r.URL.Path, m.prefix) {
@@ -97,6 +90,13 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if isNoAuthPath(r.URL.Path) {
+		// REST calls that don't require authentication also do not
+		// need a CSRF token.
+		m.next.ServeHTTP(w, r)
+		return
+	}
+
 	// Verify the CSRF token
 	token := r.Header.Get("X-CSRF-Token-" + m.unique)
 	if !m.validToken(token) {
diff --git a/lib/config/guiconfiguration.pb.go b/lib/config/guiconfiguration.pb.go
index 94779a75c14..23155203e32 100644
--- a/lib/config/guiconfiguration.pb.go
+++ b/lib/config/guiconfiguration.pb.go
@@ -37,6 +37,7 @@ type GUIConfiguration struct {
 	Debugging                 bool     `protobuf:"varint,11,opt,name=debugging,proto3" json:"debugging" xml:"debugging,attr"`
 	InsecureSkipHostCheck     bool     `protobuf:"varint,12,opt,name=insecure_skip_host_check,json=insecureSkipHostCheck,proto3" json:"insecureSkipHostcheck" xml:"insecureSkipHostcheck,omitempty"`
 	InsecureAllowFrameLoading bool     `protobuf:"varint,13,opt,name=insecure_allow_frame_loading,json=insecureAllowFrameLoading,proto3" json:"insecureAllowFrameLoading" xml:"insecureAllowFrameLoading,omitempty"`
+	SendBasicAuthPrompt       bool     `protobuf:"varint,14,opt,name=send_basic_auth_prompt,json=sendBasicAuthPrompt,proto3" json:"sendBasicAuthPrompt" xml:"sendBasicAuthPrompt,attr"`
 }
 
 func (m *GUIConfiguration) Reset()         { *m = GUIConfiguration{} }
@@ -79,60 +80,63 @@ func init() {
 func init() { proto.RegisterFile("lib/config/guiconfiguration.proto", fileDescriptor_2a9586d611855d64) }
 
 var fileDescriptor_2a9586d611855d64 = []byte{
-	// 837 bytes of a gzipped FileDescriptorProto
+	// 888 bytes of a gzipped FileDescriptorProto
 	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x55, 0xcd, 0x6e, 0xdb, 0x46,
-	0x10, 0x16, 0x5b, 0x47, 0xb2, 0xb6, 0xae, 0x60, 0xb0, 0x4d, 0xcb, 0x04, 0x0d, 0xd7, 0x51, 0xd8,
-	0xc2, 0x01, 0x02, 0x39, 0x71, 0x5a, 0x24, 0xf0, 0xa1, 0x80, 0x1c, 0x20, 0x4d, 0x60, 0x17, 0x08,
-	0xe8, 0xfa, 0x92, 0x0b, 0xb1, 0x22, 0xd7, 0xd2, 0x42, 0xfc, 0x2b, 0x77, 0x09, 0x4b, 0x87, 0xf6,
-	0x19, 0x0a, 0xf5, 0x5c, 0xa0, 0xcf, 0xd0, 0x4b, 0x5f, 0x21, 0x37, 0xe9, 0x54, 0xe4, 0xb4, 0x40,
-	0xa4, 0x1b, 0x8f, 0x3c, 0xe6, 0x54, 0xec, 0xf2, 0x47, 0xa2, 0xac, 0xd4, 0xbd, 0xed, 0x7c, 0xf3,
-	0xcd, 0x7c, 0x33, 0xc3, 0x19, 0x10, 0xdc, 0x75, 0x49, 0xef, 0xc0, 0x0e, 0xfc, 0x0b, 0xd2, 0x3f,
-	0xe8, 0xc7, 0x24, 0x7b, 0xc5, 0x11, 0x62, 0x24, 0xf0, 0x3b, 0x61, 0x14, 0xb0, 0x40, 0xad, 0x67,
-	0xe0, 0xed, 0x5b, 0x2b, 0x54, 0x14, 0xb3, 0x81, 0x17, 0x38, 0x38, 0xa3, 0xdc, 0x6e, 0xe2, 0x11,
-	0xcb, 0x9e, 0xed, 0xb7, 0x3b, 0x60, 0xf7, 0x87, 0xf3, 0x97, 0xcf, 0x56, 0x13, 0xa9, 0x3d, 0xd0,
-	0xc0, 0x3e, 0xea, 0xb9, 0xd8, 0xd1, 0x94, 0x3d, 0x65, 0x7f, 0xfb, 0xf8, 0x45, 0xc2, 0x61, 0x01,
-	0xa5, 0x1c, 0xde, 0x1d, 0x79, 0xee, 0x51, 0x3b, 0xb7, 0x1f, 0x20, 0xc6, 0xa2, 0xf6, 0x9e, 0x83,
-	0x2f, 0x50, 0xec, 0xb2, 0xa3, 0x36, 0x8b, 0x62, 0xdc, 0x4e, 0xa6, 0xc6, 0xce, 0xaa, 0xff, 0xfd,
-	0xd4, 0xd8, 0x12, 0x0e, 0xb3, 0xc8, 0xa2, 0xfe, 0x02, 0x1a, 0xc8, 0x71, 0x22, 0x4c, 0xa9, 0xf6,
-	0xd1, 0x9e, 0xb2, 0xdf, 0x3c, 0xb6, 0xe7, 0x1c, 0x02, 0x13, 0x5d, 0x76, 0x33, 0x54, 0x28, 0xe6,
-	0x84, 0x94, 0xc3, 0x6f, 0xa4, 0x62, 0x6e, 0xaf, 0x88, 0x3d, 0x3a, 0x7c, 0xd2, 0x79, 0xd8, 0x79,
-	0xd8, 0x79, 0x74, 0xf4, 0xf4, 0xf1, 0xd3, 0x6f, 0xdb, 0xef, 0xa7, 0x46, 0xab, 0x0a, 0x4d, 0x66,
-	0xc6, 0x4a, 0x52, 0xb3, 0x48, 0xa9, 0xfe, 0xa3, 0x80, 0x2f, 0x63, 0x9f, 0x8c, 0x2c, 0x1a, 0xd8,
-	0x43, 0xcc, 0xac, 0x10, 0x47, 0x1e, 0xa1, 0x94, 0x04, 0x3e, 0xd5, 0x3e, 0x96, 0xf5, 0xfc, 0xa1,
-	0xcc, 0x39, 0xd4, 0x4c, 0x74, 0x79, 0xee, 0x93, 0xd1, 0x99, 0x64, 0xbd, 0x5a, 0x92, 0x12, 0x0e,
-	0x6f, 0xc6, 0x9b, 0x1c, 0x29, 0x87, 0x5f, 0xcb, 0x62, 0x37, 0x7a, 0x1f, 0x04, 0x1e, 0x61, 0xd8,
-	0x0b, 0xd9, 0x58, 0x8c, 0x08, 0x5e, 0xc3, 0x99, 0xcc, 0x8c, 0x0f, 0x16, 0x60, 0x6e, 0x96, 0x57,
-	0x9f, 0x83, 0xad, 0x98, 0xe2, 0x48, 0xdb, 0x92, 0x4d, 0x1c, 0x26, 0x1c, 0x4a, 0x3b, 0xe5, 0xf0,
-	0xf3, 0xac, 0x2c, 0x8a, 0xa3, 0x6a, 0x15, 0xad, 0x2a, 0x64, 0x4a, 0xbe, 0xfa, 0x1a, 0x6c, 0x87,
-	0x88, 0xd2, 0xcb, 0x20, 0x72, 0xb4, 0x1b, 0x32, 0xd7, 0xf7, 0x09, 0x87, 0x25, 0x96, 0x72, 0xa8,
-	0xc9, 0x7c, 0x05, 0x50, 0xcd, 0xa9, 0x5e, 0x85, 0xcd, 0x32, 0x56, 0xf5, 0x40, 0x53, 0x6c, 0xa4,
-	0x25, 0x56, 0x52, 0xab, 0xef, 0x29, 0xfb, 0xad, 0xc3, 0xdd, 0x4e, 0xb6, 0xaa, 0x9d, 0x6e, 0xcc,
-	0x06, 0x3f, 0x06, 0x0e, 0xce, 0xe4, 0x50, 0x6e, 0x95, 0x72, 0x05, 0xb0, 0x26, 0x77, 0x15, 0x36,
-	0xcb, 0x58, 0x15, 0x83, 0x46, 0x4c, 0xb1, 0xc5, 0x5c, 0xaa, 0x35, 0xe4, 0x3a, 0x9f, 0xce, 0x39,
-	0x6c, 0x8a, 0xc1, 0x52, 0xfc, 0xd3, 0xe9, 0x59, 0xc2, 0x61, 0x3d, 0x96, 0xaf, 0x94, 0xc3, 0x96,
-	0x54, 0x61, 0x2e, 0xcd, 0xd6, 0x3a, 0x99, 0x1a, 0xdb, 0x85, 0x91, 0x4e, 0x8d, 0x9c, 0x37, 0x99,
-	0x19, 0xcb, 0x70, 0x53, 0x82, 0x2e, 0x15, 0x32, 0x28, 0x24, 0xd6, 0x10, 0x8f, 0xb5, 0x6d, 0x39,
-	0x30, 0x21, 0x53, 0xef, 0xbe, 0x7a, 0x79, 0x82, 0xc7, 0x42, 0x03, 0x85, 0xe4, 0x04, 0x8f, 0x53,
-	0x0e, 0xbf, 0xc8, 0x3a, 0x09, 0xc9, 0x10, 0x8f, 0xab, 0x7d, 0xec, 0xae, 0x83, 0x93, 0x99, 0x91,
-	0x67, 0x30, 0xf3, 0x78, 0xf5, 0x77, 0x05, 0xdc, 0x24, 0x3e, 0xc5, 0x76, 0x1c, 0x61, 0x0b, 0x39,
-	0x1e, 0xf1, 0x2d, 0x64, 0xdb, 0xe2, 0x8e, 0x9a, 0xb2, 0x39, 0x2b, 0xe1, 0xf0, 0xb3, 0x82, 0xd0,
-	0x15, 0xfe, 0xae, 0x74, 0xa7, 0x1c, 0xde, 0x93, 0xc2, 0x1b, 0x7c, 0xd5, 0x2a, 0xee, 0xfc, 0x27,
-	0xc3, 0xdc, 0x94, 0x5c, 0x3d, 0x01, 0x37, 0xd8, 0x00, 0x7b, 0x58, 0x03, 0xb2, 0xf5, 0xef, 0x12,
-	0x0e, 0x33, 0x20, 0xe5, 0xf0, 0x4e, 0x36, 0x53, 0x61, 0xad, 0x9c, 0x6e, 0xfe, 0x10, 0x37, 0xdb,
-	0xc8, 0xdf, 0x66, 0x16, 0xa2, 0x9e, 0x83, 0xa6, 0x83, 0x7b, 0x71, 0xbf, 0x4f, 0xfc, 0xbe, 0xf6,
-	0x89, 0xec, 0xea, 0x49, 0xc2, 0xe1, 0x12, 0x2c, 0xb7, 0xb9, 0x44, 0xca, 0xcf, 0xd5, 0xaa, 0x42,
-	0xe6, 0x32, 0x48, 0xfd, 0x5b, 0x01, 0x5a, 0x39, 0x39, 0x3a, 0x24, 0xa1, 0x35, 0x08, 0x28, 0xb3,
-	0xec, 0x01, 0xb6, 0x87, 0xda, 0x8e, 0x94, 0xf9, 0x55, 0xdc, 0x75, 0xc1, 0x39, 0x1b, 0x92, 0xf0,
-	0x45, 0x40, 0x99, 0x24, 0x94, 0x77, 0xbd, 0xd1, 0xbb, 0x76, 0xd7, 0xd7, 0x70, 0xd2, 0xa9, 0xb1,
-	0x59, 0xc4, 0xbc, 0x02, 0x3f, 0x13, 0xb0, 0xfa, 0x97, 0x02, 0xbe, 0x5a, 0x7e, 0x73, 0xd7, 0x0d,
-	0x2e, 0xad, 0x8b, 0x08, 0x79, 0xd8, 0x72, 0x03, 0xe4, 0x88, 0x21, 0x7d, 0x2a, 0xab, 0xff, 0x39,
-	0xe1, 0xf0, 0x56, 0xf9, 0x75, 0x04, 0xed, 0xb9, 0x60, 0x9d, 0x66, 0xa4, 0x94, 0xc3, 0xfb, 0xd5,
-	0x05, 0x58, 0x67, 0x54, 0xbb, 0xb8, 0xf7, 0x3f, 0x78, 0xe6, 0x87, 0xe5, 0x8e, 0x4f, 0xde, 0xbc,
-	0xd3, 0x6b, 0xb3, 0x77, 0x7a, 0xed, 0xcd, 0x5c, 0x57, 0x66, 0x73, 0x5d, 0xf9, 0x6d, 0xa1, 0xd7,
-	0xfe, 0x5c, 0xe8, 0xca, 0x6c, 0xa1, 0xd7, 0xde, 0x2e, 0xf4, 0xda, 0xeb, 0xfb, 0x7d, 0xc2, 0x06,
-	0x71, 0xaf, 0x63, 0x07, 0xde, 0x01, 0x1d, 0xfb, 0x36, 0x1b, 0x10, 0xbf, 0xbf, 0xf2, 0x5a, 0xfe,
-	0xc1, 0x7a, 0x75, 0xf9, 0xbb, 0x7a, 0xfc, 0x6f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xd8, 0x8c, 0xef,
-	0xc0, 0x01, 0x07, 0x00, 0x00,
+	0x10, 0x16, 0x5b, 0x47, 0xb2, 0xb6, 0x89, 0x60, 0xb0, 0x4d, 0xca, 0x04, 0x0d, 0xd7, 0x51, 0xd8,
+	0xc2, 0x01, 0x02, 0x39, 0x71, 0x5a, 0x24, 0xf0, 0xa1, 0x80, 0x1c, 0x20, 0x4d, 0x60, 0x17, 0x30,
+	0xe8, 0xfa, 0x92, 0x0b, 0xb1, 0x22, 0xd7, 0xd2, 0x42, 0xfc, 0x2b, 0x77, 0x09, 0x5b, 0x87, 0xf6,
+	0x01, 0x7a, 0x2a, 0xdc, 0x73, 0x81, 0x3e, 0x43, 0x2f, 0x7d, 0x85, 0xdc, 0xa4, 0x53, 0xd1, 0xd3,
+	0x02, 0x91, 0xd1, 0x0b, 0x8f, 0x3c, 0xe6, 0x54, 0xec, 0xf2, 0x47, 0xa2, 0x4c, 0x37, 0xb9, 0xed,
+	0x7c, 0xf3, 0xcd, 0x7c, 0x33, 0xc3, 0x19, 0x10, 0xdc, 0x73, 0xc9, 0x60, 0xdb, 0x0e, 0xfc, 0x13,
+	0x32, 0xdc, 0x1e, 0xc6, 0x24, 0x7b, 0xc5, 0x11, 0x62, 0x24, 0xf0, 0x7b, 0x61, 0x14, 0xb0, 0x40,
+	0x6d, 0x66, 0xe0, 0x9d, 0xdb, 0x4b, 0x54, 0x14, 0xb3, 0x91, 0x17, 0x38, 0x38, 0xa3, 0xdc, 0x69,
+	0xe3, 0x33, 0x96, 0x3d, 0xbb, 0xff, 0xde, 0x00, 0x1b, 0xdf, 0x1d, 0xbf, 0x7a, 0xbe, 0x9c, 0x48,
+	0x1d, 0x80, 0x16, 0xf6, 0xd1, 0xc0, 0xc5, 0x8e, 0xa6, 0x6c, 0x2a, 0x5b, 0xeb, 0x7b, 0x2f, 0x13,
+	0x0e, 0x0b, 0x28, 0xe5, 0xf0, 0xde, 0x99, 0xe7, 0xee, 0x76, 0x73, 0xfb, 0x21, 0x62, 0x2c, 0xea,
+	0x6e, 0x3a, 0xf8, 0x04, 0xc5, 0x2e, 0xdb, 0xed, 0xb2, 0x28, 0xc6, 0xdd, 0x64, 0x6a, 0x5c, 0x5f,
+	0xf6, 0xbf, 0x9b, 0x1a, 0x6b, 0xc2, 0x61, 0x16, 0x59, 0xd4, 0x9f, 0x40, 0x0b, 0x39, 0x4e, 0x84,
+	0x29, 0xd5, 0x3e, 0xda, 0x54, 0xb6, 0xda, 0x7b, 0xf6, 0x9c, 0x43, 0x60, 0xa2, 0xd3, 0x7e, 0x86,
+	0x0a, 0xc5, 0x9c, 0x90, 0x72, 0xf8, 0x95, 0x54, 0xcc, 0xed, 0x25, 0xb1, 0xc7, 0x3b, 0x4f, 0x7b,
+	0x8f, 0x7a, 0x8f, 0x7a, 0x8f, 0x77, 0x9f, 0x3d, 0x79, 0xf6, 0x75, 0xf7, 0xdd, 0xd4, 0xe8, 0x54,
+	0xa1, 0xf3, 0x99, 0xb1, 0x94, 0xd4, 0x2c, 0x52, 0xaa, 0x7f, 0x2b, 0xe0, 0xf3, 0xd8, 0x27, 0x67,
+	0x16, 0x0d, 0xec, 0x31, 0x66, 0x56, 0x88, 0x23, 0x8f, 0x50, 0x4a, 0x02, 0x9f, 0x6a, 0x1f, 0xcb,
+	0x7a, 0x7e, 0x57, 0xe6, 0x1c, 0x6a, 0x26, 0x3a, 0x3d, 0xf6, 0xc9, 0xd9, 0x91, 0x64, 0x1d, 0x2e,
+	0x48, 0x09, 0x87, 0x37, 0xe3, 0x3a, 0x47, 0xca, 0xe1, 0x97, 0xb2, 0xd8, 0x5a, 0xef, 0xc3, 0xc0,
+	0x23, 0x0c, 0x7b, 0x21, 0x9b, 0x88, 0x11, 0xc1, 0xf7, 0x70, 0xce, 0x67, 0xc6, 0x95, 0x05, 0x98,
+	0xf5, 0xf2, 0xea, 0x0b, 0xb0, 0x16, 0x53, 0x1c, 0x69, 0x6b, 0xb2, 0x89, 0x9d, 0x84, 0x43, 0x69,
+	0xa7, 0x1c, 0x7e, 0x96, 0x95, 0x45, 0x71, 0x54, 0xad, 0xa2, 0x53, 0x85, 0x4c, 0xc9, 0x57, 0x5f,
+	0x83, 0xf5, 0x10, 0x51, 0x7a, 0x1a, 0x44, 0x8e, 0x76, 0x4d, 0xe6, 0xfa, 0x36, 0xe1, 0xb0, 0xc4,
+	0x52, 0x0e, 0x35, 0x99, 0xaf, 0x00, 0xaa, 0x39, 0xd5, 0xcb, 0xb0, 0x59, 0xc6, 0xaa, 0x1e, 0x68,
+	0x8b, 0x8d, 0xb4, 0xc4, 0x4a, 0x6a, 0xcd, 0x4d, 0x65, 0xab, 0xb3, 0xb3, 0xd1, 0xcb, 0x56, 0xb5,
+	0xd7, 0x8f, 0xd9, 0xe8, 0xfb, 0xc0, 0xc1, 0x99, 0x1c, 0xca, 0xad, 0x52, 0xae, 0x00, 0x56, 0xe4,
+	0x2e, 0xc3, 0x66, 0x19, 0xab, 0x62, 0xd0, 0x8a, 0x29, 0xb6, 0x98, 0x4b, 0xb5, 0x96, 0x5c, 0xe7,
+	0x83, 0x39, 0x87, 0x6d, 0x31, 0x58, 0x8a, 0x7f, 0x38, 0x38, 0x4a, 0x38, 0x6c, 0xc6, 0xf2, 0x95,
+	0x72, 0xd8, 0x91, 0x2a, 0xcc, 0xa5, 0xd9, 0x5a, 0x27, 0x53, 0x63, 0xbd, 0x30, 0xd2, 0xa9, 0x91,
+	0xf3, 0xce, 0x67, 0xc6, 0x22, 0xdc, 0x94, 0xa0, 0x4b, 0x85, 0x0c, 0x0a, 0x89, 0x35, 0xc6, 0x13,
+	0x6d, 0x5d, 0x0e, 0x4c, 0xc8, 0x34, 0xfb, 0x87, 0xaf, 0xf6, 0xf1, 0x44, 0x68, 0xa0, 0x90, 0xec,
+	0xe3, 0x49, 0xca, 0xe1, 0xad, 0xac, 0x93, 0x90, 0x8c, 0xf1, 0xa4, 0xda, 0xc7, 0xc6, 0x2a, 0x78,
+	0x3e, 0x33, 0xf2, 0x0c, 0x66, 0x1e, 0xaf, 0xfe, 0xa6, 0x80, 0x9b, 0xc4, 0xa7, 0xd8, 0x8e, 0x23,
+	0x6c, 0x21, 0xc7, 0x23, 0xbe, 0x85, 0x6c, 0x5b, 0xdc, 0x51, 0x5b, 0x36, 0x67, 0x25, 0x1c, 0x7e,
+	0x5a, 0x10, 0xfa, 0xc2, 0xdf, 0x97, 0xee, 0x94, 0xc3, 0xfb, 0x52, 0xb8, 0xc6, 0x57, 0xad, 0xe2,
+	0xee, 0xff, 0x32, 0xcc, 0xba, 0xe4, 0xea, 0x3e, 0xb8, 0xc6, 0x46, 0xd8, 0xc3, 0x1a, 0x90, 0xad,
+	0x7f, 0x93, 0x70, 0x98, 0x01, 0x29, 0x87, 0x77, 0xb3, 0x99, 0x0a, 0x6b, 0xe9, 0x74, 0xf3, 0x87,
+	0xb8, 0xd9, 0x56, 0xfe, 0x36, 0xb3, 0x10, 0xf5, 0x18, 0xb4, 0x1d, 0x3c, 0x88, 0x87, 0x43, 0xe2,
+	0x0f, 0xb5, 0x4f, 0x64, 0x57, 0x4f, 0x13, 0x0e, 0x17, 0x60, 0xb9, 0xcd, 0x25, 0x52, 0x7e, 0xae,
+	0x4e, 0x15, 0x32, 0x17, 0x41, 0xea, 0x5f, 0x0a, 0xd0, 0xca, 0xc9, 0xd1, 0x31, 0x09, 0xad, 0x51,
+	0x40, 0x99, 0x65, 0x8f, 0xb0, 0x3d, 0xd6, 0xae, 0x4b, 0x99, 0x9f, 0xc5, 0x5d, 0x17, 0x9c, 0xa3,
+	0x31, 0x09, 0x5f, 0x06, 0x94, 0x49, 0x42, 0x79, 0xd7, 0xb5, 0xde, 0x95, 0xbb, 0x7e, 0x0f, 0x27,
+	0x9d, 0x1a, 0xf5, 0x22, 0xe6, 0x25, 0xf8, 0xb9, 0x80, 0xd5, 0x3f, 0x15, 0xf0, 0xc5, 0xe2, 0x9b,
+	0xbb, 0x6e, 0x70, 0x6a, 0x9d, 0x44, 0xc8, 0xc3, 0x96, 0x1b, 0x20, 0x47, 0x0c, 0xe9, 0x86, 0xac,
+	0xfe, 0xc7, 0x84, 0xc3, 0xdb, 0xe5, 0xd7, 0x11, 0xb4, 0x17, 0x82, 0x75, 0x90, 0x91, 0x52, 0x0e,
+	0x1f, 0x54, 0x17, 0x60, 0x95, 0x51, 0xed, 0xe2, 0xfe, 0x07, 0xf0, 0xcc, 0xab, 0xe5, 0xd4, 0x5f,
+	0x14, 0x70, 0x8b, 0x62, 0xdf, 0xb1, 0x06, 0x88, 0x12, 0xdb, 0x92, 0x17, 0x1f, 0x46, 0x81, 0x17,
+	0x32, 0xad, 0x23, 0xcb, 0x3d, 0x16, 0x9b, 0x2a, 0x18, 0x7b, 0x82, 0x20, 0x0e, 0xff, 0x50, 0xba,
+	0x53, 0x0e, 0x75, 0x59, 0x68, 0x8d, 0xaf, 0xfc, 0xce, 0xda, 0x55, 0x4e, 0xb3, 0x2e, 0xe5, 0xde,
+	0xfe, 0x9b, 0xb7, 0x7a, 0x63, 0xf6, 0x56, 0x6f, 0xbc, 0x99, 0xeb, 0xca, 0x6c, 0xae, 0x2b, 0xbf,
+	0x5e, 0xe8, 0x8d, 0x3f, 0x2e, 0x74, 0x65, 0x76, 0xa1, 0x37, 0xfe, 0xb9, 0xd0, 0x1b, 0xaf, 0x1f,
+	0x0c, 0x09, 0x1b, 0xc5, 0x83, 0x9e, 0x1d, 0x78, 0xdb, 0x74, 0xe2, 0xdb, 0x6c, 0x44, 0xfc, 0xe1,
+	0xd2, 0x6b, 0xf1, 0x3b, 0x1d, 0x34, 0xe5, 0xbf, 0xf3, 0xc9, 0x7f, 0x01, 0x00, 0x00, 0xff, 0xff,
+	0xfe, 0x19, 0xb2, 0x3c, 0x8e, 0x07, 0x00, 0x00,
 }
 
 func (m *GUIConfiguration) Marshal() (dAtA []byte, err error) {
@@ -155,6 +159,16 @@ func (m *GUIConfiguration) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 	_ = i
 	var l int
 	_ = l
+	if m.SendBasicAuthPrompt {
+		i--
+		if m.SendBasicAuthPrompt {
+			dAtA[i] = 1
+		} else {
+			dAtA[i] = 0
+		}
+		i--
+		dAtA[i] = 0x70
+	}
 	if m.InsecureAllowFrameLoading {
 		i--
 		if m.InsecureAllowFrameLoading {
@@ -327,6 +341,9 @@ func (m *GUIConfiguration) ProtoSize() (n int) {
 	if m.InsecureAllowFrameLoading {
 		n += 2
 	}
+	if m.SendBasicAuthPrompt {
+		n += 2
+	}
 	return n
 }
 
@@ -696,6 +713,26 @@ func (m *GUIConfiguration) Unmarshal(dAtA []byte) error {
 				}
 			}
 			m.InsecureAllowFrameLoading = bool(v != 0)
+		case 14:
+			if wireType != 0 {
+				return fmt.Errorf("proto: wrong wireType = %d for field SendBasicAuthPrompt", wireType)
+			}
+			var v int
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowGuiconfiguration
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				v |= int(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
+			m.SendBasicAuthPrompt = bool(v != 0)
 		default:
 			iNdEx = preIndex
 			skippy, err := skipGuiconfiguration(dAtA[iNdEx:])
diff --git a/proto/lib/config/guiconfiguration.proto b/proto/lib/config/guiconfiguration.proto
index ed0927599a3..c636bb11a9d 100644
--- a/proto/lib/config/guiconfiguration.proto
+++ b/proto/lib/config/guiconfiguration.proto
@@ -20,4 +20,5 @@ message GUIConfiguration {
     bool     debugging                    = 11 [(ext.xml) = "debugging,attr"];
     bool     insecure_skip_host_check     = 12 [(ext.xml) = "insecureSkipHostcheck,omitempty", (ext.json) = "insecureSkipHostcheck"];
     bool     insecure_allow_frame_loading = 13 [(ext.xml) = "insecureAllowFrameLoading,omitempty"];
+    bool     send_basic_auth_prompt       = 14 [(ext.xml) = "sendBasicAuthPrompt,attr"];
 }


Test patch:
diff --git a/lib/api/api_test.go b/lib/api/api_test.go
index be85ed6ee7d..c85b86ddf3e 100644
--- a/lib/api/api_test.go
+++ b/lib/api/api_test.go
@@ -554,15 +554,184 @@ func testHTTPRequest(t *testing.T, baseURL string, tc httpTestCase, apikey strin
 	}
 }
 
+func hasSessionCookie(cookies []*http.Cookie) bool {
+	for _, cookie := range cookies {
+		if cookie.MaxAge >= 0 && strings.HasPrefix(cookie.Name, "sessionid") {
+			return true
+		}
+	}
+	return false
+}
+
+func httpGet(url string, basicAuthUsername string, basicAuthPassword string, xapikeyHeader string, authorizationBearer string, cookies []*http.Cookie, t *testing.T) *http.Response {
+	req, err := http.NewRequest("GET", url, nil)
+	for _, cookie := range cookies {
+		req.AddCookie(cookie)
+	}
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if basicAuthUsername != "" || basicAuthPassword != "" {
+		req.SetBasicAuth(basicAuthUsername, basicAuthPassword)
+	}
+
+	if xapikeyHeader != "" {
+		req.Header.Set("X-API-Key", xapikeyHeader)
+	}
+
+	if authorizationBearer != "" {
+		req.Header.Set("Authorization", "Bearer "+authorizationBearer)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return resp
+}
+
+func httpPost(url string, body map[string]string, t *testing.T) *http.Response {
+	bodyBytes, err := json.Marshal(body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return resp
+}
+
 func TestHTTPLogin(t *testing.T) {
 	t.Parallel()
 
+	httpGetBasicAuth := func(url string, username string, password string) *http.Response {
+		return httpGet(url, username, password, "", "", nil, t)
+	}
+
+	httpGetXapikey := func(url string, xapikeyHeader string) *http.Response {
+		return httpGet(url, "", "", xapikeyHeader, "", nil, t)
+	}
+
+	httpGetAuthorizationBearer := func(url string, bearer string) *http.Response {
+		return httpGet(url, "", "", "", bearer, nil, t)
+	}
+
+	testWith := func(sendBasicAuthPrompt bool, expectedOkStatus int, expectedFailStatus int, path string) {
+		cfg := newMockedConfig()
+		cfg.GUIReturns(config.GUIConfiguration{
+			User:                "üser",
+			Password:            "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
+			RawAddress:          "127.0.0.1:0",
+			APIKey:              testAPIKey,
+			SendBasicAuthPrompt: sendBasicAuthPrompt,
+		})
+		baseURL, cancel, err := startHTTP(cfg)
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Cleanup(cancel)
+		url := baseURL + path
+
+		t.Run(fmt.Sprintf("%d path", expectedOkStatus), func(t *testing.T) {
+			t.Run("no auth is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "", "")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for unauthed request", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("incorrect password is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "üser", "rksmrgs")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for incorrect password", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("incorrect username is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "user", "räksmörgås") // string literals in Go source code are in UTF-8
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for incorrect username", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("UTF-8 auth works", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "üser", "räksmörgås") // string literals in Go source code are in UTF-8
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for authed request (UTF-8)", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("ISO-8859-1 auth works", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "\xfcser", "r\xe4ksm\xf6rg\xe5s") // escaped ISO-8859-1
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for authed request (ISO-8859-1)", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("bad X-API-Key is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetXapikey(url, testAPIKey+"X")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for bad API key", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("good X-API-Key is accepted", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetXapikey(url, testAPIKey)
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for API key", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("bad Bearer is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetAuthorizationBearer(url, testAPIKey+"X")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for bad API key", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("good Bearer is accepted", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetAuthorizationBearer(url, testAPIKey)
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for API key", expectedOkStatus, resp.StatusCode)
+				}
+			})
+		})
+	}
+
+	testWith(true, http.StatusOK, http.StatusUnauthorized, "/meta.js")
+	testWith(true, http.StatusNotFound, http.StatusUnauthorized, "/any-path/that/does/nooooooot/match-any/noauth-pattern")
+
+	testWith(false, http.StatusOK, http.StatusForbidden, "/meta.js")
+	testWith(false, http.StatusNotFound, http.StatusForbidden, "/any-path/that/does/nooooooot/match-any/noauth-pattern")
+}
+
+func TestHtmlFormLogin(t *testing.T) {
+	t.Parallel()
+
 	cfg := newMockedConfig()
 	cfg.GUIReturns(config.GUIConfiguration{
-		User:       "üser",
-		Password:   "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
-		RawAddress: "127.0.0.1:0",
-		APIKey:     testAPIKey,
+		User:                "üser",
+		Password:            "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
+		SendBasicAuthPrompt: false,
 	})
 	baseURL, cancel, err := startHTTP(cfg)
 	if err != nil {
@@ -570,119 +739,137 @@ func TestHTTPLogin(t *testing.T) {
 	}
 	t.Cleanup(cancel)
 
-	t.Run("no auth is rejected", func(t *testing.T) {
-		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for unauthed request", resp.StatusCode)
-		}
-	})
+	loginUrl := baseURL + "/rest/noauth/auth/password"
+	resourceUrl := baseURL + "/meta.js"
+	resourceUrl404 := baseURL + "/any-path/that/does/nooooooot/match-any/noauth-pattern"
 
-	t.Run("incorrect password is rejected", func(t *testing.T) {
+	performLogin := func(username string, password string) *http.Response {
+		return httpPost(loginUrl, map[string]string{"username": username, "password": password}, t)
+	}
+
+	performResourceRequest := func(url string, cookies []*http.Cookie) *http.Response {
+		return httpGet(url, "", "", "", "", cookies, t)
+	}
+
+	testNoAuthPath := func(noAuthPath string) {
+		t.Run("auth is not needed for "+noAuthPath, func(t *testing.T) {
+			t.Parallel()
+			resp := httpGet(baseURL+noAuthPath, "", "", "", "", nil, t)
+			if resp.StatusCode != http.StatusOK {
+				t.Errorf("Unexpected non-200 return code %d at %s", resp.StatusCode, noAuthPath)
+			}
+			if hasSessionCookie(resp.Cookies()) {
+				t.Errorf("Unexpected session cookie at " + noAuthPath)
+			}
+		})
+	}
+	testNoAuthPath("/index.html")
+	testNoAuthPath("/rest/svc/lang")
+
+	t.Run("incorrect password is rejected with 403", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("üser", "rksmrgs")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performLogin("üser", "rksmrgs") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect password", resp.StatusCode)
+		}
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect password")
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for incorrect password", resp.StatusCode)
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect password", resp.StatusCode)
 		}
 	})
 
-	t.Run("incorrect username is rejected", func(t *testing.T) {
+	t.Run("incorrect username is rejected with 403", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("user", "räksmörgås") // string literals in Go source code are in UTF-8
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performLogin("user", "räksmörgås") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect username", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for incorrect username", resp.StatusCode)
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect username")
+		}
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect username", resp.StatusCode)
 		}
 	})
 
 	t.Run("UTF-8 auth works", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("üser", "räksmörgås") // string literals in Go source code are in UTF-8
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		// JSON is always UTF-8, so ISO-8859-1 case is not applicable
+		resp := performLogin("üser", "räksmörgås") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusNoContent {
+			t.Errorf("Unexpected non-204 return code %d for authed request (UTF-8)", resp.StatusCode)
 		}
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
 		if resp.StatusCode != http.StatusOK {
 			t.Errorf("Unexpected non-200 return code %d for authed request (UTF-8)", resp.StatusCode)
 		}
 	})
 
-	t.Run("ISO-8859-1 auth work", func(t *testing.T) {
+	t.Run("form login is not applicable to other URLs", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("\xfcser", "r\xe4ksm\xf6rg\xe5s") // escaped ISO-8859-1
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := httpPost(baseURL+"/meta.js", map[string]string{"username": "üser", "password": "räksmörgås"}, t)
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect form login URL", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for authed request (ISO-8859-1)", resp.StatusCode)
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect form login URL")
 		}
 	})
 
-	t.Run("bad X-API-Key is rejected", func(t *testing.T) {
+	t.Run("invalid URL returns 403 before auth and 404 after auth", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("X-API-Key", testAPIKey+"X")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performResourceRequest(resourceUrl404, nil)
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for unauthed request", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for bad API key", resp.StatusCode)
+		resp = performLogin("üser", "räksmörgås")
+		if resp.StatusCode != http.StatusNoContent {
+			t.Errorf("Unexpected non-204 return code %d for authed request", resp.StatusCode)
+		}
+		resp = performResourceRequest(resourceUrl404, resp.Cookies())
+		if resp.StatusCode != http.StatusNotFound {
+			t.Errorf("Unexpected non-404 return code %d for authed request", resp.StatusCode)
 		}
 	})
+}
 
-	t.Run("good X-API-Key is accepted", func(t *testing.T) {
-		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("X-API-Key", testAPIKey)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for API key", resp.StatusCode)
-		}
+func TestApiCache(t *testing.T) {
+	t.Parallel()
+
+	cfg := newMockedConfig()
+	cfg.GUIReturns(config.GUIConfiguration{
+		RawAddress: "127.0.0.1:0",
+		APIKey:     testAPIKey,
 	})
+	baseURL, cancel, err := startHTTP(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(cancel)
+
+	httpGet := func(url string, bearer string) *http.Response {
+		return httpGet(url, "", "", "", bearer, nil, t)
+	}
 
-	t.Run("bad Bearer is rejected", func(t *testing.T) {
+	t.Run("meta.js has no-cache headers", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("Authorization", "Bearer "+testAPIKey+"X")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for bad API key", resp.StatusCode)
+		url := baseURL + "/meta.js"
+		resp := httpGet(url, testAPIKey)
+		if resp.Header.Get("Cache-Control") != "max-age=0, no-cache, no-store" {
+			t.Errorf("Expected no-cache headers at %s", url)
 		}
 	})
 
-	t.Run("good Bearer is accepted", func(t *testing.T) {
+	t.Run("/rest/ has no-cache headers", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("Authorization", "Bearer "+testAPIKey)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for API key", resp.StatusCode)
+		url := baseURL + "/rest/system/version"
+		resp := httpGet(url, testAPIKey)
+		if resp.Header.Get("Cache-Control") != "max-age=0, no-cache, no-store" {
+			t.Errorf("Expected no-cache headers at %s", url)
 		}
 	})
 }
@@ -774,6 +961,10 @@ func TestCSRFRequired(t *testing.T) {
 		}
 	}
 
+	if csrfTokenValue == "" {
+		t.Fatal("Failed to initialize CSRF test: no CSRF cookie returned from " + baseURL)
+	}
+
 	t.Run("/rest without a token should fail", func(t *testing.T) {
 		t.Parallel()
 		resp, err := cli.Get(baseURL + "/rest/system/config")