diff --git a/backend/web/README.md b/backend/web/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..663e3a5aee5ce22deba309909fb42d71270b0868
--- /dev/null
+++ b/backend/web/README.md
@@ -0,0 +1,5 @@
+This `web` directory is responsible for authentication frontend components.
+
+It uses Tailwind for CSS; when making UI changes open a terminal in the `web` directory and run
+
+`npx tailwindcss -i ./static/src/input.css -o ./static/css/main.css --watch`
diff --git a/backend/web/static/base.js b/backend/web/static/base.js
index bf79733c8c7eaf990a84556bf5753d67430be9e9..e09a07bfd2df2766026d802109fa5c60ae562f49 100644
--- a/backend/web/static/base.js
+++ b/backend/web/static/base.js
@@ -57,7 +57,7 @@ function check_flow_auth() {
 		// 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';
+		window.location.href = api_url + "/self-service/settings/browser";
 		return;
 	}
 
@@ -215,8 +215,8 @@ 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', '');
+				$("#pills-password-tab").tab("show");
+				Cookies.set("stackspin_context", "");
 			}
 		},
 		complete: function (obj) {
@@ -235,7 +235,7 @@ function flow_settings() {
 					// 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();
@@ -643,3 +643,50 @@ $.urlParam = function (name) {
 	}
 	return decodeURI(results[1]) || 0;
 };
+
+//////////////////////////////////////
+// sign up form for the demo instance
+//////////////////////////////////////
+
+function submitSignup() {
+	let result = document.querySelector("#signup-result");
+	let email = document.querySelector("#signup-email");
+	let xhr = new XMLHttpRequest();
+	xhr.responseType = "json";
+	let url = "/web/demo-user";
+	xhr.open("POST", url, true);
+	xhr.setRequestHeader("Content-Type", "application/json");
+	xhr.onreadystatechange = function () {
+		if (xhr.readyState === 4) {
+			// In the success case, we get a plain (json) string; in the error
+			// case, we get an object with `errorMessage` property.
+			if (typeof this.response == "object" && "errorMessage" in this.response) {
+				window.console.log("Error in sign-up request.");
+				result.classList.remove("alert-success");
+				result.classList.add("alert-danger");
+				if (
+					this.response.errorMessage ==
+					"[KratosError] Unable to insert or update resource because a resource with that value exists already"
+				) {
+					result.innerHTML = "A user with that email address already exists.";
+				} else if (
+					this.response.errorMessage ==
+					"[KratosError] The request was malformed or contained invalid parameters"
+				) {
+					result.innerHTML = "That doesn't appear to be a valid email address.";
+				} else {
+					result.innerHTML = this.response.errorMessage;
+				}
+			} else {
+				result.classList.add("alert-success");
+				result.classList.remove("alert-danger");
+				result.innerHTML = this.response;
+			}
+			result.style.visibility = "visible";
+		}
+	};
+	// Converting JSON data to string
+	var data = JSON.stringify({ email: email.value });
+	// Sending data with the request
+	xhr.send(data);
+}
diff --git a/backend/web/static/css/main.css b/backend/web/static/css/main.css
index 5821f614e31f12dd454c3dbf55c262af33858611..698b3ea1be893ce15d67c0125d932e40f232dfdc 100644
--- a/backend/web/static/css/main.css
+++ b/backend/web/static/css/main.css
@@ -1,5 +1,5 @@
 /*
-! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com
+! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
 */
 
 /*
@@ -191,6 +191,10 @@ select,
 textarea {
   font-family: inherit;
   /* 1 */
+  font-feature-settings: inherit;
+  /* 1 */
+  font-variation-settings: inherit;
+  /* 1 */
   font-size: 100%;
   /* 1 */
   font-weight: inherit;
@@ -341,6 +345,14 @@ menu {
   padding: 0;
 }
 
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+  padding: 0;
+}
+
 /*
 Prevent resizing textareas horizontally by default.
 */
@@ -422,6 +434,16 @@ video {
   display: none;
 }
 
+label {
+  margin-bottom: 0.25rem;
+  display: block;
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+  font-weight: 500;
+  --tw-text-opacity: 1;
+  color: rgb(55 65 81 / var(--tw-text-opacity));
+}
+
 *, ::before, ::after {
   --tw-border-spacing-x: 0;
   --tw-border-spacing-y: 0;
@@ -573,14 +595,18 @@ video {
   margin-top: 0px;
 }
 
-.mt-10 {
-  margin-top: 2.5rem;
-}
-
 .block {
   display: block;
 }
 
+.inline-block {
+  display: inline-block;
+}
+
+.inline {
+  display: inline;
+}
+
 .flex {
   display: flex;
 }
@@ -621,6 +647,14 @@ video {
   flex-direction: column;
 }
 
+.place-content-end {
+  place-content: end;
+}
+
+.place-items-center {
+  place-items: center;
+}
+
 .items-center {
   align-items: center;
 }
@@ -633,12 +667,35 @@ video {
   justify-content: center;
 }
 
+.justify-between {
+  justify-content: space-between;
+}
+
+.justify-items-end {
+  justify-items: end;
+}
+
+.gap-4 {
+  gap: 1rem;
+}
+
 .space-y-8 > :not([hidden]) ~ :not([hidden]) {
   --tw-space-y-reverse: 0;
   margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
   margin-bottom: calc(2rem * var(--tw-space-y-reverse));
 }
 
+.divide-y > :not([hidden]) ~ :not([hidden]) {
+  --tw-divide-y-reverse: 0;
+  border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
+  border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
+}
+
+.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
+  --tw-divide-opacity: 1;
+  border-color: rgb(229 231 235 / var(--tw-divide-opacity));
+}
+
 .rounded-lg {
   border-radius: 0.5rem;
 }
@@ -659,6 +716,20 @@ video {
   border-top-width: 2px;
 }
 
+.border-t-\[1px\] {
+  border-top-width: 1px;
+}
+
+.border-gray-300 {
+  --tw-border-opacity: 1;
+  border-color: rgb(209 213 219 / var(--tw-border-opacity));
+}
+
+.border-primary-400 {
+  --tw-border-opacity: 1;
+  border-color: rgb(85 198 204 / var(--tw-border-opacity));
+}
+
 .border-transparent {
   border-color: transparent;
 }
@@ -688,6 +759,10 @@ video {
   background-color: rgb(255 255 255 / var(--tw-bg-opacity));
 }
 
+.p-4 {
+  padding: 1rem;
+}
+
 .p-8 {
   padding: 2rem;
 }
@@ -731,21 +806,25 @@ video {
   padding-bottom: 1.25rem;
 }
 
+.pt-0 {
+  padding-top: 0px;
+}
+
 .pt-2 {
   padding-top: 0.5rem;
 }
 
-.text-center {
-  text-align: center;
+.pt-4 {
+  padding-top: 1rem;
 }
 
 .text-right {
   text-align: right;
 }
 
-.text-2xl {
-  font-size: 1.5rem;
-  line-height: 2rem;
+.text-lg {
+  font-size: 1.125rem;
+  line-height: 1.75rem;
 }
 
 .text-sm {
@@ -753,6 +832,11 @@ video {
   line-height: 1.25rem;
 }
 
+.text-xs {
+  font-size: 0.75rem;
+  line-height: 1rem;
+}
+
 .font-bold {
   font-weight: 700;
 }
@@ -761,12 +845,8 @@ video {
   font-weight: 500;
 }
 
-.leading-9 {
-  line-height: 2.25rem;
-}
-
-.tracking-tight {
-  letter-spacing: -0.025em;
+.leading-6 {
+  line-height: 1.5rem;
 }
 
 .text-gray-400 {
@@ -774,6 +854,11 @@ video {
   color: rgb(156 163 175 / var(--tw-text-opacity));
 }
 
+.text-gray-500 {
+  --tw-text-opacity: 1;
+  color: rgb(107 114 128 / var(--tw-text-opacity));
+}
+
 .text-gray-900 {
   --tw-text-opacity: 1;
   color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -801,6 +886,33 @@ video {
   box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 }
 
+.outline {
+  outline-style: solid;
+}
+
+.outline-1 {
+  outline-width: 1px;
+}
+
+.outline-primary-300 {
+  outline-color: #7AE5EA;
+}
+
+.ring-1 {
+  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.ring-inset {
+  --tw-ring-inset: inset;
+}
+
+.ring-gray-300 {
+  --tw-ring-opacity: 1;
+  --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
+}
+
 .hover\:bg-primary-700:hover {
   --tw-bg-opacity: 1;
   background-color: rgb(21 121 131 / var(--tw-bg-opacity));
@@ -836,104 +948,23 @@ video {
   --tw-ring-offset-width: 2px;
 }
 
-@media (min-width: 640px) {
-  .sm\:mx-auto {
-    margin-left: auto;
-    margin-right: auto;
-  }
-
-  .sm\:w-full {
-    width: 100%;
-  }
-}
-
 @media (min-width: 768px) {
   .md\:mb-0 {
     margin-bottom: 0px;
   }
 
-  .md\:min-h-full {
-    min-height: 100%;
-  }
-
-  .md\:w-20 {
-    width: 5rem;
-  }
-
-  .md\:w-1\/6 {
-    width: 16.666667%;
-  }
-
-  .md\:w-2\/6 {
-    width: 33.333333%;
-  }
-
-  .md\:w-1\/4 {
-    width: 25%;
-  }
-
-  .md\:w-1\/2 {
-    width: 50%;
-  }
-
-  .md\:w-1\/3 {
-    width: 33.333333%;
-  }
-
-  .md\:w-80 {
-    width: 20rem;
-  }
-
-  .md\:w-96 {
-    width: 24rem;
-  }
-
   .md\:w-2\/3 {
     width: 66.666667%;
   }
 
-  .md\:max-w-md {
-    max-width: 28rem;
-  }
-
-  .md\:max-w-sm {
-    max-width: 24rem;
-  }
-
   .md\:max-w-lg {
     max-width: 32rem;
   }
-
-  .md\:flex-col {
-    flex-direction: column;
-  }
-
-  .md\:justify-center {
-    justify-content: center;
-  }
 }
 
 @media (min-width: 1024px) {
-  .lg\:w-1\/4 {
-    width: 25%;
-  }
-
-  .lg\:w-1\/3 {
-    width: 33.333333%;
-  }
-
   .lg\:px-8 {
     padding-left: 2rem;
     padding-right: 2rem;
   }
 }
-
-@media (min-width: 1280px) {
-  .xl\:w-1\/3 {
-    width: 33.333333%;
-  }
-
-  .xl\:max-w-md {
-    max-width: 28rem;
-  }
-}
diff --git a/backend/web/static/favicon.ico b/backend/web/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..863fd60c8b337f1e043f9d7b466e4e8c0961768f
Binary files /dev/null and b/backend/web/static/favicon.ico differ
diff --git a/backend/web/static/src/input.css b/backend/web/static/src/input.css
index b0f89e7fa77802f0c67191a3fd86e36426dae8c1..5081208add5520489884b19d3ef36ac1c1df5c7f 100644
--- a/backend/web/static/src/input.css
+++ b/backend/web/static/src/input.css
@@ -2,6 +2,12 @@
 @tailwind components;
 @tailwind utilities;
 
+@layer base {
+	label {
+		@apply block text-sm font-medium text-gray-700 mb-1;
+	}
+}
+
 @layer components {
 	#pills-tab .nav-link.active {
 		@apply bg-transparent text-gray-800 border-b-2 text-left border-b-primary-700 hover:bg-primary-700 py-2 px-4 rounded-none;
diff --git a/backend/web/templates/base.html b/backend/web/templates/base.html
index 8cadf73805a5667e70d05b91a294ba8448974280..d5221bfc684a042d70007826c5949538352183e2 100644
--- a/backend/web/templates/base.html
+++ b/backend/web/templates/base.html
@@ -1,49 +1,55 @@
-<!doctype html>
+<!DOCTYPE html>
 <html class="h-full bg-white">
-<link rel="stylesheet" href="static/style.css">
+	<link rel="stylesheet" href="static/style.css" />
+	<link rel="icon" type="image/x-icon" href="/static/favicon.ico" />
 
-<link rel="stylesheet" href="static/css/bootstrap.min.css">
-<link href="static/css/main.css" rel="stylesheet">
-<script src="static/js/jquery-3.6.0.min.js"></script>
-<script src="static/js/bootstrap.bundle.min.js"></script>
-<script src="static/js/js.cookie.min.js"></script>
-<script src="static/base.js"></script>
-
-<title>Your Stackspin Account</title>
+	<link rel="stylesheet" href="static/css/bootstrap.min.css" />
+	<link href="static/css/main.css" rel="stylesheet" />
+	<script src="static/js/jquery-3.6.0.min.js"></script>
+	<script src="static/js/bootstrap.bundle.min.js"></script>
+	<script src="static/js/js.cookie.min.js"></script>
+	<script src="static/base.js"></script>
 
+	<title>Your Stackspin Account</title>
 </html>
 
-
-
 <body class="h-full bg-gray-50">
-
-
-    <script>
-        var api_url = '{{ api_url }}';
-
-        // Actions
-        $(document).ready(function () {
-            check_flow_expired();
-        });
-
-    </script>
-
-
-
-    <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
-        <div class="mx-auto w-full h-full md:w-2/3 md:max-w-lg bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8">
-
-            <div id="contentFlowExpired" class='alert alert-warning' style='display:none'>Your request has expired.
-                Please resubmit your request.</div>
-
-
-            <a href="{{ dashboard_url }}"><img class="mx-auto h-10 w-auto mb-4" src='static/logo.svg' /></a>
-            <!-- <h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">Your Stackspin
-                account</h2> -->
-
-
-            <div id='messages'></div>
-
-            {% block content %}{% endblock %}
-        </div>
-    </div>
\ No newline at end of file
+	<script>
+		var api_url = "{{ api_url }}";
+
+		// Actions
+		$(document).ready(function () {
+			check_flow_expired();
+		});
+	</script>
+
+	<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
+		<div
+			class="mx-auto w-full h-full md:w-2/3 md:max-w-lg bg-white shadow rounded-lg space-y-8 mb-4 md:mb-0 p-8 pt-0"
+		>
+			<div
+				id="contentFlowExpired"
+				class="alert alert-warning"
+				style="display: none"
+			>
+				Your request has expired. Please resubmit your request.
+			</div>
+
+			<div class="login-header flex flex-col place-items-center">
+				<a href="{{ dashboard_url }}" class="block"
+					><img class="mx-auto h-10 w-auto" src="static/logo.svg" />
+				</a>
+				{% if demo %}
+				<span
+					class="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300"
+				>
+					Demo Instance
+				</span>
+				{% endif %}
+			</div>
+			<div id="messages"></div>
+
+			{% block content %}{% endblock %}
+		</div>
+	</div>
+</body>
diff --git a/backend/web/templates/login.html b/backend/web/templates/login.html
index e29bee76ffd961650a2d670c8a2bf586b1e666cf..9a6c891bc1ea3ce35ba34180f7a917eec7e9e4c6 100644
--- a/backend/web/templates/login.html
+++ b/backend/web/templates/login.html
@@ -1,106 +1,95 @@
-{% extends 'base.html' %}
-
-{% block content %}
+{% extends 'base.html' %} {% block content %}
 
 <script>
-  var api_url = '{{ api_url }}';
+	var api_url = '{{ api_url }}';
+
+	// Actions
+	$(document).ready(function () {
+		flow_login();
+	});
 
-  // Actions
-  $(document).ready(function () {
-    flow_login();
-  });
 
-  // Render messages
-  {% for message in messages %}
-  renderMessage('{{ message['id'] }}', '{{ message['message'] }}', '{{ message['type'] }}');
-  {% endfor %}
+	{% for message in messages %}
+	renderMessage('{{ message['id'] }}', '{{ message['message'] }}', '{{ message['type'] }}');
+	{% endfor %}
+
+	// On demo instance, submit the sign-up form via ajax
+	// so we can show the result on the same page.
+	{% if demo %}
+	$('#signup-form').submit((event) => {
+		event.preventDefault();
+		window.console.log("signup submit");
+		submitSignup();
+	});
+	{% endif %}
 </script>
 
-<div id="contentMessages"></div>
-<div id="contentLogin_password"></div>
-<div id="contentLogin_totp"></div>
-<div id="contentHelp" class="flex flex-col font-medium text-primary-600 mt-0 pt-2 border-t-gray-50 border-t-2">
-  <a href='recovery' class="block w-full text-right hover:text-primary-700 mb-2">Set new
-    password</a><a href='https://stackspin.net'
-    class="block text-right w-full text-sm text-gray-400 hover:text-primary-500">What
-    is
-    Stackspin?</a>
-</div>
 {% if demo %}
-<br>
-<script>
-  function submitSignup() {
-    let result = document.querySelector('#signup-result');
-    let email = document.querySelector('#signup-email');
-    let xhr = new XMLHttpRequest();
-    xhr.responseType = 'json';
-    let url = "/web/demo-user";
-    xhr.open("POST", url, true);
-    xhr.setRequestHeader("Content-Type", "application/json");
-    xhr.onreadystatechange = function () {
-      if (xhr.readyState === 4) {
-        // In the success case, we get a plain (json) string; in the error
-        // case, we get an object with `errorMessage` property.
-        if (typeof (this.response) == 'object' && 'errorMessage' in this.response) {
-          window.console.log("Error in sign-up request.");
-          result.classList.remove('alert-success');
-          result.classList.add('alert-danger');
-          if (this.response.errorMessage == '[KratosError] Unable to insert or update resource because a resource with that value exists already') {
-            result.innerHTML = "A user with that email address already exists.";
-          } else if (this.response.errorMessage == '[KratosError] The request was malformed or contained invalid parameters') {
-            result.innerHTML = "That doesn't appear to be a valid email address.";
-          } else {
-            result.innerHTML = this.response.errorMessage;
-          }
-        } else {
-          result.classList.add('alert-success');
-          result.classList.remove('alert-danger');
-          result.innerHTML = this.response;
-        }
-        result.style.visibility = 'visible';
-      }
-    };
-    // Converting JSON data to string
-    var data = JSON.stringify({ "email": email.value });
-    // Sending data with the request
-    xhr.send(data);
-  }
-  $(() => {
-    // Submit the sign-up form via ajax so we can show the result on the same
-    // page.
-    $('#signup-form').submit((event) => {
-      event.preventDefault();
-      window.console.log("signup submit");
-      submitSignup();
-    });
-  })
-</script>
-<h4>Sign up for this demo instance</h4>
-Enter your email address here to create an account on this Stackspin
-instance.
-<div class="alert alert-warning" style="margin-top: 1em;">
-  Warning: this is a demo instance! That means that:
-  <ul>
-    <li>Anyone can create an account on this same instance, like yourself,
-      and will share the same set of users and data. So any data you create
-      or upload, including the email address you enter here, becomes
-      essentially public information.</li>
-    <li>Every night (Europe/Amsterdam time), this instance gets automatically
-      reset to an empty state, so any data you create or upload will be
-      destroyed.</li>
-  </ul>
+
+<div
+	class="rounded-md bg-gray-50 border border-gray-300 outline outline-1 outline-primary-300 p-4 flex flex-col gap-4 text-xs text-gray-500"
+>
+	<p>Please keep in mind this is a demo instance:</p>
+	<ul class="divide-y divide-gray-200">
+		<li class="py-2">Anyone can create an account, just like yourself.</li>
+		<li class="py-2">
+			Any data you upload, including your email address, can be accessible by
+			other users.
+		</li>
+		<li class="py-2">
+			The demo is automatically reset every night (Europe/Amsterdam time),
+			removing all users and their data.
+		</li>
+	</ul>
 </div>
+<h1 class="text-lg leading-6 font-bold text-gray-900">
+	Create your Stackspin demo account:
+</h1>
 <form id="signup-form">
-  <div class="form-group">
-    <label for="signup-email">Email address</label>
-    <input type="email" class="form-control" id="signup-email" name="signup-email"
-      placeholder="Your email address to sign up with.">
-  </div>
-  <div class="form-group">
-    <input type="submit" class="btn btn-primary" value="Sign up">
-    <div id="signup-result" class="alert" style="visibility: hidden; margin-top: 1em;"></div>
-  </div>
+	<label for="signup-email">E-mail address</label>
+	<div class="form-group flex justify-between gap-4">
+		<input
+			type="email"
+			class="form-control"
+			id="signup-email"
+			name="signup-email"
+			placeholder="Please provide your e-mail address"
+		/>
+		<button
+			type="submit"
+			class="inline-flex pointer h-10 items-center px-2.5 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
+		>
+			Create
+		</button>
+	</div>
+	<div
+		id="signup-result"
+		class="form-group rounded-md bg-gray-50 border border-gray-300 outline outline-1 outline-primary-300 p-4 flex flex-col gap-4 text-xs text-gray-500"
+		style="visibility: hidden"
+	></div>
 </form>
+
+<h1
+	class="text-lg mt-0 leading-6 font-bold text-gray-900 pt-4 border-t-[1px] border-primary-400"
+>
+	If you already have an account, log in:
+</h1>
+
 {% endif %}
 
-{% endblock %}
\ No newline at end of file
+<div id="contentMessages"></div>
+<div id="contentLogin_password"></div>
+<div id="contentLogin_totp"></div>
+<footer
+	class="font-medium text-primary-600 mt-0 pt-2 border-t-gray-50 border-t-2 flex justify-end"
+>
+	<div id="contentHelp" class="flex flex-col text-right">
+		<a href="recovery" class="hover:text-primary-700 mb-2">Set new password</a
+		><a
+			href="https://stackspin.net"
+			class="text-sm text-gray-400 hover:text-primary-500"
+			>What is Stackspin?</a
+		>
+	</div>
+	<footer>{% endblock %}</footer>
+</footer>