diff --git a/backend/web/login/login.py b/backend/web/login/login.py
index e03b03ed3f07a347a40897f4b49aa0e485cb0647..2abbcc01e3bb35fea3b599c7ae08c4e5c27d3bb6 100644
--- a/backend/web/login/login.py
+++ b/backend/web/login/login.py
@@ -4,9 +4,10 @@ Hydra for OIDC sessions and MariaDB for application and role specifications.
 The application provides also several command line options to interact with
 the user entries in the database(s)"""
 
+import ast
+import json
 import urllib.parse
 import urllib.request
-import ast
 
 import ory_hydra_client
 # hydra v2
@@ -129,17 +130,23 @@ def login():
     """
 
     # Check if we are logged in:
-    identity = get_auth()
+    (identity, auth_response) = get_auth()
+    # We ignore the potential `auth_response` in this case: that's for telling
+    # the user they have to upgrade their session to include a second factor,
+    # but we're already on the login page so there's no use for that here --
+    # they'd be redirected by Kratos back to this same login page anyway,
+    # creating a redirect loop. Chances are that if `auth_response` is not
+    # None, we're actually serving or processing the TOTP form here.
 
     # List to contain messages pushed to the frontend
     messages = list()
 
-
     refresh = False
     flow = request.args.get("flow")
     if flow:
         cookies = request.headers['cookie']
         flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies)
+        # current_app.logger.info("flow found in login: {}".format(flow))
         refresh = flow['refresh']
         if refresh:
             message = {
@@ -241,7 +248,12 @@ def auth():
         abort(400, description="Challenge required when requesting authorization")
 
     # Check if we are logged in:
-    identity = get_auth()
+    (identity, auth_response) = get_auth()
+
+    if auth_response is not None:
+        # According to `get_auth`, we have to send the user a response already,
+        # probably a redirect to let the user provide their second factor.
+        return auth_response
 
     # If the user is not logged in yet, we redirect to the login page
     # but before we do that, we set the "flow_state" cookie to auth.
@@ -464,7 +476,7 @@ def status():
     Show if there is an user is logged in. If not shows: not-auth
     """
 
-    auth_status = get_auth()
+    (auth_status, auth_response) = get_auth()
 
     if auth_status:
         return auth_status.id
@@ -475,27 +487,43 @@ def get_auth():
     """Checks if user is logged in
     Queries the cookies. If an authentication cookie is found, it
     checks with Kratos if the cookie is still valid. If so,
-    the profile is returned. Otherwise False is returned.
-    :return: Profile or False if not logged in
+    the profile is returned. Otherwise False is returned, possibly with a
+    response to send to the user, for redirecting them to the kratos-suggested
+    url, for providing 2FA in particular.
+    :return: (Profile, None) or (False, None) or (False, Response)
     """
 
     cookie = get_kratos_cookie()
     if not cookie:
-        return False
+        return False, None
 
     # Given a cookie, check if it is valid and get the profile
     try:
         api_response = kratos_public_frontend_api.to_session(cookie=cookie)
 
         # Get all traits from ID
-        return api_response.identity
+        return api_response.identity, None
 
     except ory_kratos_client.ApiException as ex:
+        # If it fails because the client needs to provide 2FA, we return a
+        # redirect response for use by the caller of this function.
+        if ex.body is not None:
+            body = json.loads(ex.body)
+            current_app.logger.info("Error in to_session: {}".format(body))
+            error_id = body.get('error', {}).get('id')
+            if error_id == 'session_aal2_required':
+                current_app.logger.info("2FA requested by Kratos. Redirecting the user.")
+                redirect_url = body.get('redirect_browser_to')
+                if redirect_url is None:
+                    response = None
+                else:
+                    response = redirect(redirect_url)
+                return False, response
         current_app.logger.error(
             f"Exception when calling to_session(): {ex}\n"
         )
 
-    return False
+    return False, None
 
 
 def get_kratos_cookie():
diff --git a/backend/web/static/base.js b/backend/web/static/base.js
index 61d08200d5409383f40d02e7aa7d32530eb10142..bf79733c8c7eaf990a84556bf5753d67430be9e9 100644
--- a/backend/web/static/base.js
+++ b/backend/web/static/base.js
@@ -16,8 +16,8 @@
 
 */
 
-// In default configuration the dashboed is on '/'. This can be overwritten
-// before calling the scripts (and configured by the flask app
+// In default configuration the dashboard is on '/'. This can be overwritten
+// before calling the scripts (and configured by the flask app).
 var dashboard_url = "";
 
 // Render a message by appending the data to the messages box. The message id is
@@ -42,6 +42,7 @@ function renderMessage(id, message, type) {
 // case.
 function check_flow_auth() {
 	var state = Cookies.get("flow_state");
+	window.console.log("check_flow_auth: flow_state=" + state);
 	var url = Cookies.get("auth_url");
 
 	// Redirect to the specified URL
@@ -51,6 +52,15 @@ function check_flow_auth() {
 		return;
 	}
 
+	if (state == "recovery") {
+		Cookies.set("flow_state", "");
+		// Set a custom cookie so the settings page knows we're in
+		// recovery context and can open the right tab.
+		Cookies.set("stackspin_context", "recovery");
+		window.location.href = api_url + '/self-service/settings/browser';
+		return;
+	}
+
 	// Some older stackspin releases, do not provide the dashboard_url,
 	// flask writes 'None' as string in that case, we want to cover those
 	// cases and revert to the default
@@ -76,7 +86,6 @@ function check_flow_expired() {
 function flow_login() {
 	var flow = $.urlParam("flow");
 	var uri = api_url + "self-service/login/flows?id=" + flow;
-
 	// Query the Kratos backend to know what fields to render for the
 	// current flow
 	$.ajax({
@@ -112,7 +121,6 @@ function flow_login() {
 function flow_settings_validate() {
 	var flow = $.urlParam("flow");
 	var uri = api_url + "self-service/settings/flows?id=" + flow;
-
 	$.ajax({
 		type: "GET",
 		url: uri,
@@ -153,6 +161,7 @@ function flow_settings() {
 		url: uri,
 		success: function (data) {
 			var state = Cookies.get("flow_state");
+			var context = Cookies.get("stackspin_context");
 
 			// If we have confirmation the settings are saved, show the
 			// notification
@@ -203,6 +212,12 @@ function flow_settings() {
 					},
 				});
 			});
+
+			// If we are in recovery context, switch to the password tab.
+			if (context == "recovery") {
+				$('#pills-password-tab').tab('show');
+				Cookies.set('stackspin_context', '');
+			}
 		},
 		complete: function (obj) {
 			// If we get a 410, the flow is expired, need to refresh the flow
@@ -211,6 +226,25 @@ function flow_settings() {
 				window.location.href = "settings";
 			}
 		},
+		error: function (xhr, textStatus, errorThrown) {
+			// Check if we got a 403 error from Kratos.
+			if (textStatus == "error" && xhr.status == 403) {
+				var response = $.parseJSON(xhr.responseText);
+				window.console.log(response);
+				if (response.error.id == "session_aal2_required") {
+					// Redirect so user can enter 2FA.
+					window.location.href = response.redirect_browser_to;
+					return;
+                                }
+			}
+			// There was another error, one we don't specifically prepared for.
+			$("#contentProfileSaveFailed").show();
+
+			// For now, this code assumes that only the password can fail
+			// validation. Other forms might need to be added in the future.
+			html = render_form(data, "password", "validation");
+			$("#contentPassword").html(html);
+		},
 	});
 }
 
diff --git a/docker-compose.yml b/docker-compose.yml
index 7a275345a40ad6ee607fff4e84f7daa809d152bd..d8861940817bcf271a7dc059792c7ba2d57b88fa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,7 +11,7 @@ services:
       - "3000:3000"
     command: "yarn start --watch --verbose"
   stackspin_proxy:
-    image: nginx:1.25.1
+    image: nginx:1.25.2
     ports:
       - "8081:8081"
     volumes:
@@ -57,7 +57,7 @@ services:
       - kube_port_mysql
     entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"]
   kube_port_kratos_admin:
-    image: bitnami/kubectl:1.27.4
+    image: bitnami/kubectl:1.27.5
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 8000
@@ -70,7 +70,7 @@ services:
         "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80",
       ]
   kube_port_hydra_admin:
-    image: bitnami/kubectl:1.27.4
+    image: bitnami/kubectl:1.27.5
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 4445
@@ -83,7 +83,7 @@ services:
         "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445",
       ]
   kube_port_kratos_public:
-    image: bitnami/kubectl:1.27.4
+    image: bitnami/kubectl:1.27.5
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     ports:
       - "8080:8080"
@@ -98,7 +98,7 @@ services:
         "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80",
       ]
   kube_port_mysql:
-    image: bitnami/kubectl:1.27.4
+    image: bitnami/kubectl:1.27.5
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 3306