diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index a9e4c5a80b950e33977b0135243475ad33d461e2..ff6359afd6d3dff947f499a8656866b23aeb0a0d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_,
 and this project adheres to `Semantic Versioning`_.
 
+`3.0b1` - 2023-02-27
+--------------------
+
+Added
+~~~~~
+
+* Support for two factor authentication via email codes and Webauthn.
+
 `3.0b0` - 2023-02-15
 --------------------
 
@@ -54,7 +62,18 @@ Changed
   * Introduce PBKDF2-SHA1 password hashing
 * Persistent database connections are now health-checked as to not fail
   requests
+<<<<<<< HEAD
 * Use write-through cache for sessions to retain them on clear_cache
+=======
+* Incorporate SPDX license list for app licenses on About page
+* [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check`
+* Frontend bundling migrated from Webpack to Vite
+* Get dashboard widgets and data checks from apps with new registration mechanism.
+* Use write-through cache for sessions to retain on clear_cache
+* Better error page with redirect option to login page when user has no permission to access a route.
+* Users now can setup as many 2FA devices as they want.
+* The 2FA profile overview was completely redesigned.
+>>>>>>> origin/master
 
 Fixed
 ~~~~~
@@ -1037,3 +1056,4 @@ Fixed
 .. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1
 .. _2.12.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.2
 .. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0
+.. _3.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0
diff --git a/aleksis/core/frontend/components/two_factor/TwoFactor.vue b/aleksis/core/frontend/components/two_factor/TwoFactor.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b6c023a55ffa3d9cf467d88109b46d78cf630ab0
--- /dev/null
+++ b/aleksis/core/frontend/components/two_factor/TwoFactor.vue
@@ -0,0 +1,123 @@
+<template>
+  <div>
+    <h1 class="mb-4">{{ $t("accounts.two_factor.title") }}</h1>
+    <div v-if="$apollo.queries.twoFactor.loading">
+      <v-skeleton-loader type="card,card"></v-skeleton-loader>
+    </div>
+    <div v-else-if="twoFactor && twoFactor.activated">
+      <v-card class="mb-4">
+        <v-card-title>
+          {{ $t("accounts.two_factor.primary_device_title") }}
+        </v-card-title>
+        <v-card-text>
+          {{ $t("accounts.two_factor.primary_device_description") }}
+        </v-card-text>
+        <v-list three-line>
+          <two-factor-device :device="twoFactor.defaultDevice" primary />
+        </v-list>
+      </v-card>
+
+      <v-card class="mb-4">
+        <v-card-title>
+          {{ $t("accounts.two_factor.other_devices_title") }}
+        </v-card-title>
+        <v-card-text>
+          {{ $t("accounts.two_factor.other_devices_description") }}
+        </v-card-text>
+        <v-list three-line>
+          <div v-for="(device, index) in twoFactor.otherDevices" :key="index">
+            <two-factor-device :device="device" />
+            <v-divider />
+          </div>
+
+          <two-factor-device-base icon="mdi-backup-restore">
+            <template #title>
+              {{ $t("accounts.two_factor.backup_codes_title") }}
+            </template>
+            <template #subtitles>
+              <v-list-item-subtitle>
+                {{ $t("accounts.two_factor.backup_codes_description") }}
+              </v-list-item-subtitle>
+              <v-list-item-subtitle>
+                {{
+                  $tc(
+                    "accounts.two_factor.backup_codes_count",
+                    twoFactor.backupTokensCount,
+                    { counter: twoFactor.backupTokensCount }
+                  )
+                }}
+              </v-list-item-subtitle>
+            </template>
+            <template #action>
+              <v-btn icon :to="{ name: 'core.twoFactor.backupTokens' }">
+                <v-icon>mdi-chevron-right</v-icon>
+              </v-btn>
+            </template>
+          </two-factor-device-base>
+        </v-list>
+        <v-card-actions>
+          <v-btn text color="primary" :to="{ name: 'core.twoFactor.add' }">
+            <v-icon left>mdi-key-plus</v-icon>
+            {{ $t("accounts.two_factor.add_authentication_method") }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+
+      <v-card class="mb-4">
+        <v-card-title>{{
+          $t("accounts.two_factor.disable_title")
+        }}</v-card-title>
+        <v-card-text>
+          {{ $t("accounts.two_factor.disable_description") }}
+        </v-card-text>
+        <v-card-actions>
+          <v-btn
+            color="red"
+            class="white--text"
+            :to="{ name: 'core.twoFactor.disable' }"
+          >
+            <v-icon left>mdi-power</v-icon>
+            {{ $t("accounts.two_factor.disable_button") }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </div>
+    <div v-else>
+      <v-card class="mb-4">
+        <v-card-title>
+          {{ $t("accounts.two_factor.enable_title") }}
+        </v-card-title>
+        <v-card-text>
+          {{ $t("accounts.two_factor.enable_description") }}
+        </v-card-text>
+        <v-card-actions>
+          <v-btn
+            color="green"
+            class="white--text"
+            :to="{ name: 'core.twoFactor.setup' }"
+          >
+            <v-icon left>mdi-power</v-icon>
+            {{ $t("accounts.two_factor.enable_button") }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </div>
+  </div>
+</template>
+
+<script>
+import gqlTwoFactor from "./twoFactor.graphql";
+import TwoFactorDevice from "./TwoFactorDevice.vue";
+import TwoFactorDeviceBase from "./TwoFactorDeviceBase.vue";
+
+export default {
+  name: "TwoFactor",
+  components: { TwoFactorDeviceBase, TwoFactorDevice },
+  apollo: {
+    twoFactor: {
+      query: gqlTwoFactor,
+      fetchPolicy: "network-only",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue b/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7af3fbe6452262e305575b4f7c1852ae79e039c5
--- /dev/null
+++ b/aleksis/core/frontend/components/two_factor/TwoFactorDevice.vue
@@ -0,0 +1,58 @@
+<template>
+  <two-factor-device-base :icon="icon">
+    <template #title>{{ device.methodVerboseName }}</template>
+    <template #subtitles>
+      <v-list-item-subtitle>
+        {{ $t("accounts.two_factor.methods." + device.methodCode) }}
+      </v-list-item-subtitle>
+      <v-list-item-subtitle
+        v-if="device.methodCode === 'call' || device.methodCode === 'sms'"
+        class="black--text"
+      >
+        {{ device.verboseName }}
+        <v-icon class="ml-1" color="green" small v-if="device.confirmed">
+          mdi-check-circle-outline
+        </v-icon>
+      </v-list-item-subtitle>
+    </template>
+    <template #action>
+      <!--      <v-btn icon color="red" v-if="!primary">-->
+      <!--        <v-icon>mdi-delete</v-icon>-->
+      <!--      </v-btn>-->
+    </template>
+  </two-factor-device-base>
+</template>
+
+<script>
+import TwoFactorDeviceBase from "./TwoFactorDeviceBase.vue";
+
+const iconMap = {
+  sms: "mdi-message-text-outline",
+  call: "mdi-phone-outline",
+  webauthn: "mdi-key-outline",
+  email: "mdi-email-outline",
+  yubikey: "mdi-key",
+};
+export default {
+  name: "TwoFactorDevice",
+  components: { TwoFactorDeviceBase },
+  computed: {
+    icon() {
+      if (this.device && this.device.methodCode in iconMap) {
+        return iconMap[this.device.methodCode];
+      }
+      return "mdi-two-factor-authentication";
+    },
+  },
+  props: {
+    device: {
+      type: Object,
+      required: true,
+    },
+    primary: {
+      type: Boolean,
+      default: false,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue b/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f8244e95a4ac593525bf92bf238ca9b86a69c1ed
--- /dev/null
+++ b/aleksis/core/frontend/components/two_factor/TwoFactorDeviceBase.vue
@@ -0,0 +1,28 @@
+<template>
+  <v-list-item>
+    <v-list-item-icon>
+      <v-icon color="grey darken-2" x-large>{{ icon }}</v-icon>
+    </v-list-item-icon>
+    <v-list-item-content>
+      <v-list-item-title class="font-weight-medium"
+        ><slot name="title"
+      /></v-list-item-title>
+      <slot name="subtitles" />
+    </v-list-item-content>
+    <v-list-item-action>
+      <slot name="action" />
+    </v-list-item-action>
+  </v-list-item>
+</template>
+
+<script>
+export default {
+  name: "TwoFactorDeviceBase",
+  props: {
+    icon: {
+      type: String,
+      required: true,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/two_factor/twoFactor.graphql b/aleksis/core/frontend/components/two_factor/twoFactor.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..431215ed1840f9ad06e61e1c8c63cf6fe0af35b7
--- /dev/null
+++ b/aleksis/core/frontend/components/two_factor/twoFactor.graphql
@@ -0,0 +1,26 @@
+{
+  twoFactor {
+    activated
+    backupTokensCount
+    defaultDevice {
+      persistentId
+      name
+      confirmed
+      action
+      verboseAction
+      verboseName
+      methodCode
+      methodVerboseName
+    }
+    otherDevices {
+      persistentId
+      name
+      confirmed
+      action
+      verboseAction
+      verboseName
+      methodCode
+      methodVerboseName
+    }
+  }
+}
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 1ae46aa9117adb9aef2a0ebb3eb6262814942e8d..3adabc47bc60d1bfca2dfd74b0c4b31591c2ea88 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -45,7 +45,30 @@
       "menu_title": "Third-party Accounts"
     },
     "two_factor": {
-      "menu_title": "2FA"
+      "menu_title": "2FA",
+      "title": "Two-Factor Authentication",
+      "primary_device_title": "Primary Authentication Device",
+      "primary_device_description": "While logging in, AlekSIS will ask you to confirm the login with the following device. If this device is not available, you can use a backup device.",
+      "other_devices_title": "Other Authentication Devices",
+      "other_devices_description": "If your primary authentication device is not available during logging in, you can use one of these devices:",
+      "methods": {
+        "generator": "You generate one-time codes using a code generator.",
+        "email": "We will send you one-time codes to your e-mail address.",
+        "sms": "We will send you one-time codes to your mobile phone number.",
+        "call": "We will call you at your mobile phone and tell you a one-time code.",
+        "webauthn": "You use a security key (either as external device or integrated in your personal device).",
+        "yubikey": "You use a Yubikey to generate one-time codes."
+      },
+      "add_authentication_method": "Add Authentication Method",
+      "backup_codes_title": "Backup Codes",
+      "backup_codes_description": "If you can't use any of your devices, you can access your account using backup codes.",
+      "backup_codes_count": "You have no backup codes remaining.|You have only one backup code remaining.|You have {counter} backup codes remaining.",
+      "disable_title": "Disable Two-Factor Authentication",
+      "disable_description": "However we strongly discourage you to do so, you can also disable two-factor authentication for your account.",
+      "disable_button": "Disable Two-Factor Authentication",
+      "enable_title": "Two-Factor Authentication Currently Disabled",
+      "enable_description": "Two-factor authentication is not enabled for your account. Enable two-factor authentication for enhanced account security.",
+      "enable_button": "Enable Two-Factor Authentication"
     }
   },
   "actions": {
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index f165248e00cf9e802e7706a220f26e3c14e4b85e..215f4d1485644883eef42f14216735547abb041a 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -729,10 +729,7 @@ const routes = [
   },
   {
     path: "/account/two_factor/",
-    component: () => import("./components/LegacyBaseTemplate.vue"),
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
+    component: () => import("./components/two_factor/TwoFactor.vue"),
     name: "core.twoFactor",
     meta: {
       inAccountMenu: true,
@@ -749,6 +746,14 @@ const routes = [
     },
     name: "core.twoFactor.setup",
   },
+  {
+    path: "/account/two_factor/add/",
+    component: () => import("./components/LegacyBaseTemplate.vue"),
+    props: {
+      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+    },
+    name: "core.twoFactor.add",
+  },
   {
     path: "/account/two_factor/qrcode/",
     component: () => import("./components/LegacyBaseTemplate.vue"),
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index c0c53f0add9d6690c866e2e178a106ed0faff0e9..deab0e28b5d93e854a020aad687a618817196536 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -24,6 +24,7 @@ from .person import PersonMutation, PersonType
 from .school_term import SchoolTermType  # noqa
 from .search import SearchResultType
 from .system_properties import SystemPropertiesType
+from .two_factor import TwoFactorType
 from .user import UserType
 
 
@@ -56,6 +57,8 @@ class Query(graphene.ObjectType):
 
     dynamic_routes = graphene.List(DynamicRouteType)
 
+    two_factor = graphene.Field(TwoFactorType)
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -149,6 +152,11 @@ class Query(graphene.ObjectType):
 
         return dynamic_routes
 
+    def resolve_two_factor(root, info, **kwargs):
+        if info.context.user.is_anonymous:
+            return None
+        return info.context.user
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
diff --git a/aleksis/core/schema/two_factor.py b/aleksis/core/schema/two_factor.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f5f81e7860b791db7434debd27150dd14644bf0
--- /dev/null
+++ b/aleksis/core/schema/two_factor.py
@@ -0,0 +1,96 @@
+import graphene
+from graphene import ObjectType
+from two_factor.plugins.email.utils import mask_email
+from two_factor.plugins.phonenumber.utils import (
+    backup_phones,
+    format_phone_number,
+    get_available_phone_methods,
+    mask_phone_number,
+)
+from two_factor.plugins.registry import registry
+from two_factor.utils import default_device
+
+
+class TwoFactorDeviceType(ObjectType):
+    persistent_id = graphene.ID()
+    name = graphene.String()
+    method_code = graphene.String()
+    verbose_name = graphene.String()
+    confirmed = graphene.Boolean()
+    method_verbose_name = graphene.String()
+
+    action = graphene.String()
+    verbose_action = graphene.String()
+
+    def get_method(root, info, **kwargs):
+        if getattr(root, "method", None):
+            return registry.get_method(root.method)
+        return registry.method_from_device(root)
+
+    def resolve_action(root, info, **kwargs):
+        method = TwoFactorDeviceType.get_method(root, info, **kwargs)
+        return method.get_action(root)
+
+    def resolve_verbose_action(root, info, **kwargs):
+        method = TwoFactorDeviceType.get_method(root, info, **kwargs)
+        return method.get_verbose_action(root)
+
+    def resolve_verbose_name(root, info, **kwargs):
+        method = TwoFactorDeviceType.get_method(root, info, **kwargs)
+        if method.code in ["sms", "call"]:
+            return mask_phone_number(format_phone_number(root.number))
+        elif method.code == "email":
+            email = root.email or root.user.email
+            if email:
+                return mask_email(email)
+
+        return method.verbose_name
+
+    def resolve_method_verbose_name(root, info, **kwargs):
+        method = TwoFactorDeviceType.get_method(root, info, **kwargs)
+        return method.verbose_name
+
+    def resolve_method_code(root, info, **kwargs):
+        method = TwoFactorDeviceType.get_method(root, info, **kwargs)
+
+        return method.code
+
+
+class PhoneTwoFactorDeviceType(TwoFactorDeviceType):
+    number = graphene.String
+
+
+class TwoFactorType(ObjectType):
+    activated = graphene.Boolean()
+    default_device = graphene.Field(TwoFactorDeviceType)
+    backup_phones = graphene.List(PhoneTwoFactorDeviceType)
+    other_devices = graphene.List(TwoFactorDeviceType)
+    backup_tokens_count = graphene.Int()
+    phone_methods_available = graphene.Boolean()
+
+    def resolve_backup_tokens_count(root, info, **kwargs):
+        try:
+            backup_tokens = root.staticdevice_set.all()[0].token_set.count()
+        except Exception:
+            backup_tokens = 0
+        return backup_tokens
+
+    def resolve_phone_methods_available(root, info, **kwargs):
+        return bool(get_available_phone_methods())
+
+    def resolve_default_device(root, info, **kwargs):
+        return default_device(root)
+
+    def resolve_activated(root, info, **kwargs):
+        return bool(default_device(root))
+
+    def resolve_other_devices(root, info, **kwargs):
+        main_device = TwoFactorType.resolve_default_device(root, info, **kwargs)
+        other_devices = []
+        for method in registry.get_methods():
+            other_devices += list(method.get_other_authentication_devices(root, main_device))
+
+        return other_devices
+
+    def resolve_backup_phones(root, info, **kwargs):
+        return backup_phones(root)
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 1f62450df2f2278426617a90d5f1a41fd02afde9..c12ae3371376a0836bdd9898753e7f8d28317daa 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -122,6 +122,7 @@ INSTALLED_APPS = [
     "html2text",
     "django_otp.plugins.otp_totp",
     "django_otp.plugins.otp_static",
+    "django_otp.plugins.otp_email",
     "django_otp",
     "otp_yubikey",
     "aleksis.core",
@@ -139,7 +140,10 @@ INSTALLED_APPS = [
     "dynamic_preferences.users.apps.UserPreferencesConfig",
     "impersonate",
     "two_factor",
+    "two_factor.plugins.email",
     "two_factor.plugins.phonenumber",
+    "two_factor.plugins.yubikey",
+    "two_factor.plugins.webauthn",
     "material",
     "ckeditor",
     "ckeditor_uploader",
@@ -756,6 +760,8 @@ if _settings.get("twilio.sid", None):
     TWILIO_AUTH_TOKEN = _settings.get("twilio.token")
     TWILIO_CALLER_ID = _settings.get("twilio.callerid")
 
+TWO_FACTOR_WEBAUTHN_RP_NAME = _settings.get("2fa.webauthn.rp_name", "AlekSIS")
+
 CELERY_BROKER_URL = _settings.get("celery.broker", REDIS_URL)
 CELERY_RESULT_BACKEND = "django-db"
 CELERY_CACHE_BACKEND = "django-cache"
diff --git a/aleksis/core/templates/two_factor/core/login.html b/aleksis/core/templates/two_factor/core/login.html
index 6145855229d9fec9f15edbd8ee597811250e2140..223c2e5237b09f7babe6cc0fa3f003a4fc3b1266 100644
--- a/aleksis/core/templates/two_factor/core/login.html
+++ b/aleksis/core/templates/two_factor/core/login.html
@@ -1,11 +1,15 @@
 {# -*- engine:django -*- #}
 {% extends "two_factor/_base_focus.html" %}
-{% load i18n phonenumber account socialaccount %}
+{% load i18n phonenumber account socialaccount two_factor_tags %}
 
 {% block browser_title %}
   {% trans "Login" %}
 {% endblock %}
 
+{% block extra_head %}
+  {{ wizard.form.media.css }}
+{% endblock %}
+
 {% block content %}
   {% get_providers as socialaccount_providers %}
 
@@ -68,13 +72,19 @@
                       {% endblocktrans %}
                     {% elif device.method == 'sms' %}
                       {% blocktrans %}
-                        We sent you a text message, please enter the tokens we
-                        sent.
+                        We sent you a text message, please enter the code we sent.
+                      {% endblocktrans %}
+                    {% elif device.method == 'email' %}
+                      {% blocktrans %}
+                        We sent you an email, please enter the code we sent.
+                      {% endblocktrans %}
+                    {% elif device.method == 'webauthn' %}
+                      {% blocktrans %}
+                        Please use your Webauthn-compatible device to authenticate.
                       {% endblocktrans %}
                     {% else %}
                       {% blocktrans %}
-                        Please enter the tokens generated by your token
-                        generator.
+                        Please enter the code generated by your code generator.
                       {% endblocktrans %}
                     {% endif %}
                   {% elif wizard.steps.current == 'backup' %}
@@ -108,12 +118,13 @@
             <div class="card-content">
               <div class="card-title">{% trans "Device currently not available?" %}</div>
               {% if other_devices %}
-                <p>{% trans "Or, alternatively, use one of your backup phones:" %}</p>
+                <p>{% trans "Alternatively, use one of your other authentication methods:" %}</p>
                 <p>
                   {% for other in other_devices %}
-                    <button name="challenge_device" value="{{ other.persistent_id }}" class="btn margin-bottom"
+                    <button name="challenge_device" value="{{ other.persistent_id }}"
+                            class="btn waves-effect waves-light margin-bottom"
                             type="submit">
-                      {{ other|device_action }}
+                      {{ other|as_action }}
                     </button>
                   {% endfor %}
                 </p>
@@ -121,7 +132,7 @@
               {% if backup_tokens %}
                 <p>{% trans "As a last resort, you can use a backup token:" %}</p>
                 <p>
-                  <button name="wizard_goto_step" type="submit" value="backup" class="btn">
+                  <button name="wizard_goto_step" type="submit" value="backup" class="btn waves-effect waves-light">
                     {% trans "Use Backup Token" %}
                   </button>
                 </p>
@@ -143,4 +154,5 @@
     </div>
   </form>
 
+  {{ wizard.form.media.js }}
 {% endblock %}
diff --git a/aleksis/core/templates/two_factor/core/otp_required.html b/aleksis/core/templates/two_factor/core/otp_required.html
index 734b0a9c8193eedef6989cae00ffe9bacf1277d6..a5f92b3e6b8e62cea5b7c3b5b1a1cecabdbaf470 100644
--- a/aleksis/core/templates/two_factor/core/otp_required.html
+++ b/aleksis/core/templates/two_factor/core/otp_required.html
@@ -7,18 +7,15 @@
       <div class="card-content white-text">
         <i class="material-icons small left">error_outline</i>
         <span class="card-title">{% blocktrans %}Permission Denied{% endblocktrans %}</span>
-        <p>{% blocktrans %}The page you requested, enforces users to verify using
-          two-factor authentication for security reasons. You need to enable these
-          security features in order to access this page.{% endblocktrans %}</p>
+        <p>{% blocktrans %}The page you requested enforces users to verify using
+          two-factor authentication for security reasons. You need to enable this
+          security feature in order to access this page.{% endblocktrans %}</p>
 
-        <p>{% blocktrans %}Two-factor authentication is not enabled for your
-          account. Enable two-factor authentication for enhanced account
-          security.{% endblocktrans %}</p>
         <p>
           <a href="javascript:history.go(-1)" class="pull-right btn waves-effect waves-light">
             {% trans "Go back" %}
           </a>
-          <a href="{% url 'two_factor:setup' %}" class="btn green waves-effect waves-light">
+          <a href="{% url 'setup_two_factor_auth' %}" class="btn green waves-effect waves-light">
             {% trans "Enable Two-Factor Authentication" %}</a>
         </p>
       </div>
diff --git a/aleksis/core/templates/two_factor/core/setup.html b/aleksis/core/templates/two_factor/core/setup.html
index a0e30472db11566a58b1c00e06b5ad6689b6e681..deccda77c3cd14b009ee5ea83654f4d37c6969fc 100644
--- a/aleksis/core/templates/two_factor/core/setup.html
+++ b/aleksis/core/templates/two_factor/core/setup.html
@@ -1,15 +1,19 @@
 {% extends "two_factor/_base_focus.html" %}
 {% load i18n %}
 
+{% block extra_head %}
+  {{ wizard.form.media.css }}
+{% endblock %}
+
 {% block content %}
-  <h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
+  <h1>{% block title %}{% trans "Add Two-Factor Authentication Method" %}{% endblock %}</h1>
 
   {% if wizard.steps.current == 'welcome' %}
     <p class="flow-text">
       {% blocktrans %}
         You are about to take your account security to the
-        next level. Follow the steps in this wizard to enable two-factor
-        authentication.
+        next level. Follow the steps in this wizard to add
+        a two-factor authentication method to your account.
       {% endblocktrans %}
     </p>
   {% elif wizard.steps.current == 'method' %}
@@ -21,9 +25,9 @@
   {% elif wizard.steps.current == 'generator' %}
     <p>
       {% blocktrans %}
-        To start using a token generator, please use your
-        favourite two factor authentication (TOTP) app to scan the QR code below.
-        Then, enter the token generated by the app.
+        To start using a code generator, please use your
+        favourite two-factor authentication (TOTP) app to scan the QR code below.
+        Then enter the token generated by the app.
       {% endblocktrans %}
     </p>
     <p>
@@ -43,6 +47,12 @@
         This number will be validated in the next step.
       {% endblocktrans %}
     </p>
+  {% elif wizard.steps.current == 'email' %}
+    <p>
+      {% blocktrans %}
+        We sent you an email, please enter the token we sent.
+      {% endblocktrans %}
+    </p>
   {% elif wizard.steps.current == 'validation' %}
     {% if challenge_succeeded %}
       {% if device.method == 'call' %}
@@ -54,19 +64,19 @@
       {% elif device.method == 'sms' %}
         <p>
           {% blocktrans %}
-            We sent you a text message, please enter the tokens we sent.
+            We sent you a text message, please enter the code we sent.
           {% endblocktrans %}
         </p>
       {% endif %}
     {% else %}
-      <p class="alert warning" role="alert">
+      <figure class="alert warning">
         {% blocktrans %}
           We've encountered an issue with the selected authentication method. Please
           go back and verify that you entered your information correctly, try
           again, or use a different authentication method instead. If the issue
           persists, contact the site administrator.
         {% endblocktrans %}
-      </p>
+      </figure>
     {% endif %}
   {% elif wizard.steps.current == 'yubikey' %}
     <p>
@@ -90,4 +100,6 @@
 
     {% include "two_factor/_wizard_actions.html" %}
   </form>
+
+  {{ wizard.form.media.js }}
 {% endblock %}
diff --git a/aleksis/core/templates/two_factor/core/setup_complete.html b/aleksis/core/templates/two_factor/core/setup_complete.html
index afd9f1722b168bfe30807f5e8a4d4cb49c4bc900..87774a97c79a8693720cdb32bf829edcb11321a8 100644
--- a/aleksis/core/templates/two_factor/core/setup_complete.html
+++ b/aleksis/core/templates/two_factor/core/setup_complete.html
@@ -8,14 +8,12 @@
 {% block content %}
   <h1>{% block title %}{% trans "Two-Factor Authentication successfully enabled" %}{% endblock %}</h1>
 
-  <div class="alert success">
-    <p>
+  <figure class="alert success">
       <i class="material-icons iconify left" data-icon="mdi:check-circle-outline"></i>
       {% blocktrans %}
         Congratulations, you've successfully enabled two-factor authentication.
       {% endblocktrans %}
-    </p>
-  </div>
+  </figure>
 
   {% if not phone_methods %}
     <a href="{% url 'two_factor:profile' %}"
@@ -28,16 +26,14 @@
       {% trans "Generate backup codes" %}
     </a>
   {% else %}
-    <div class="warning">
-      <p>
+    <figure class="alert warning">
         <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>
         {% blocktrans %}
           However, it might happen that you don't have access to
-          your primary token device. To enable account recovery, generate backup codes
-          or add a phone number.
+          your primary device. To enable account recovery, generate backup codes
+          or add other authentication methods.
         {% endblocktrans %}
-      </p>
-    </div>
+    </figure>
     <a href="{% url 'two_factor:profile' %}"
        class="btn btn-primary waves-effect waves-light">
       <i class="material-icons iconify left" data-icon="mdi:arrow-left"></i>
@@ -47,9 +43,9 @@
       <i class="material-icons iconify left" data-icon="mdi:key-outline"></i>
       {% trans "Generate backup codes" %}
     </a>
-    <a href="{% url 'two_factor:phone_create' %}" class="btn green waves-effect waves-light">
-      <i class="material-icons iconify left" data-icon="mdi:phone-plus"></i>
-      {% trans "Add Phone Number" %}
+    <a href="{% url 'setup_two_factor_auth' %}" class="btn green waves-effect waves-light">
+      <i class="material-icons iconify left" data-icon="mdi:key-plus"></i>
+      {% trans "Add Another Authentication Method" %}
     </a>
   {% endif %}
 
diff --git a/aleksis/core/templates/two_factor/profile/profile.html b/aleksis/core/templates/two_factor/profile/profile.html
deleted file mode 100644
index fe96135d0dfae6fd16ffad4fece50148115c8b69..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/two_factor/profile/profile.html
+++ /dev/null
@@ -1,92 +0,0 @@
-{% extends "two_factor/_base_focus.html" %}
-{% load i18n phonenumber %}
-
-{% block browser_title %}
-  {% trans "Account Security" %}
-{% endblock %}
-
-{% block content %}
-  <h1>
-    {% block title %}{% trans "Account Security" %}{% endblock %}
-  </h1>
-
-  {% if default_device %}
-    {% if default_device_type == 'TOTPDevice' %}
-      <p>{% trans "Tokens will be generated by your token generator." %}</p>
-    {% elif default_device_type == 'PhoneDevice' %}
-      <p>{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}</p>
-    {% elif default_device_type == 'RemoteYubikeyDevice' %}
-      <p>{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p>
-    {% endif %}
-
-    {% if available_phone_methods %}
-      <h2>{% trans "Backup Phone Numbers" %}</h2>
-      <p>{% blocktrans %}If your primary method is not available, we are able to
-        send backup tokens to the phone numbers listed below.{% endblocktrans %}</p>
-      <ul class="collection">
-        {% for phone in backup_phones %}
-          <li class="collection-item">
-            {{ phone|device_action }}
-            <form method="post" action="{% url 'two_factor:phone_delete' phone.id %}"
-                  onsubmit="return confirm('Are you sure?')">
-              {% csrf_token %}
-              <button class="btn-flat red-text waves-effect waves-red" type="submit">{% trans "Unregister" %}</button>
-            </form>
-          </li>
-        {% endfor %}
-      </ul>
-      <p>
-        <a href="{% url 'two_factor:phone_create' %}" class="btn green waves-effect waves-light">
-          <i class="material-icons iconify left" data-icon="mdi:phone-plus"></i>
-          {% trans "Add Phone Number" %}
-        </a>
-      </p>
-    {% endif %}
-
-    <h2>{% trans "Backup Tokens" %}</h2>
-    <p>
-      {% blocktrans %}If you don't have any device with you, you can access
-        your account using backup tokens.{% endblocktrans %}
-      {% blocktrans count counter=backup_tokens %}
-        You have only one backup token remaining.
-      {% plural %}
-        You have {{ counter }} backup tokens remaining.
-      {% endblocktrans %}
-    </p>
-    <p>
-      <a href="{% url 'two_factor:backup_tokens' %}" class="btn primary waves-effect waves-light">
-        <i class="material-icons iconify left" data-icon="mdi:key-outline"></i>
-        {% trans "Show Codes" %}
-      </a>
-    </p>
-
-    <h2>{% trans "Disable Two-Factor Authentication" %}</h2>
-    <p>
-      {% blocktrans %}
-        However we strongly discourage you to do so, you can
-        also disable two-factor authentication for your account.
-      {% endblocktrans %}
-    </p>
-    <p>
-      <a class="btn red waves-effect waves-light" href="{% url 'two_factor:disable' %}">
-        <i class="material-icons iconify left" data-icon="mdi:power"></i>
-        {% trans "Disable Two-Factor Authentication" %}
-      </a>
-    </p>
-  {% else %}
-    <p class="flow-text">
-      {% blocktrans %}
-        Two-factor authentication is not enabled for your
-        account. Enable two-factor authentication for enhanced account
-        security.
-      {% endblocktrans %}
-    </p>
-
-    <p>
-      <a href="{% url 'two_factor:setup' %}" class="green btn waves-effect waves-light ">
-        <i class="material-icons iconify left" data-icon="mdi:key-outline"></i>
-        {% trans "Enable Two-Factor Authentication" %}
-      </a>
-    </p>
-  {% endif %}
-{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 00286829a4fc09d6bf8e2da4a44733281a749f98..d2c02ed4a5815bfb113fa0aff19700b153b20eb4 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -81,6 +81,12 @@ urlpatterns = [
                 path("invitations/", include("invitations.urls")),
                 path("status/", views.SystemStatus.as_view(), name="system_status"),
                 path("", include(tf_urls)),
+                path("account/login/", views.TwoFactorLoginView.as_view()),
+                path(
+                    "account/two_factor/add/",
+                    views.TwoFactorSetupView.as_view(),
+                    name="setup_two_factor_auth",
+                ),
                 path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"),
                 path(
                     "school_terms/create/",
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 4779fd8a650045436b71c17d9fa262d625ece8ea..e9536003df379d5f97d1cf895d27c2a7b1623600 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -58,6 +58,8 @@ from oauth2_provider.views import AuthorizationView
 from reversion import set_user
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin, permission_required
+from two_factor import views as two_factor_views
+from two_factor.utils import devices_for_user
 from two_factor.views.core import LoginView as AllAuthLoginView
 
 from aleksis.core.data_checks import DataCheck, check_data
@@ -1513,3 +1515,31 @@ class CustomAuthorizationView(AuthorizationView):
         context = super().get_context_data(**kwargs)
         context["no_menu"] = True
         return context
+
+
+class TwoFactorSetupView(two_factor_views.SetupView):
+    def get(self, request, *args, **kwargs):
+        return super(two_factor_views.SetupView, self).get(request, *args, **kwargs)
+
+    def get_device(self, **kwargs):
+        device = super().get_device(**kwargs)
+
+        # Ensure that the device is named "backup" if it is a phone device
+        # to ensure compatibility with django_two_factor_auth
+        method = self.get_method()
+        if device and method.code in ("call", "sms"):
+            device.name = "backup"
+        return device
+
+
+class TwoFactorLoginView(two_factor_views.LoginView):
+    def get_devices(self):
+        user = self.get_user()
+
+        return devices_for_user(user)
+
+    def get_other_devices(self, main_device):
+        other_devices = self.get_devices()
+        other_devices = list(filter(lambda x: not isinstance(x, type(main_device)), other_devices))
+
+        return other_devices
diff --git a/docs/admin/16_config_options.rst b/docs/admin/16_config_options.rst
index d87d2a3c6a7054ae847684019817f74a1324bab5..15e474708a76f1261efa820c9129ca204800d4c6 100644
--- a/docs/admin/16_config_options.rst
+++ b/docs/admin/16_config_options.rst
@@ -35,11 +35,6 @@ Example configuration file::
     [maintenance]
     debug = true
 
-    # Two factor authentication with yubikey enabled, optional
-    [2fa]
-    enabled = true
-    yubikey = { enabled = true }
-
     # Authentication via LDAP, optional
     [ldap]
     uri = "ldaps://ldap.myschool.edu"
diff --git a/docs/user/02_personal_account.rst b/docs/user/02_personal_account.rst
index b1b0a596890a75ba4cdce866d9be5d2d4829d518..fbfdd346ba3de0ee56119c17ecf598f7190d5d74 100644
--- a/docs/user/02_personal_account.rst
+++ b/docs/user/02_personal_account.rst
@@ -27,7 +27,8 @@ Setup two-factor authentication
   :alt: Configure two factor authentication
 
 AlekSIS provides two factor authentication using hardware tokens such as
-yubikeys which can generate OTPs or OTP application.
+yubikeys which can generate OTPs or OTP application. Additionally,
+all devices are supported that make use of FIDO U2F.
 
 To configure the second factor, visit `Account → 2FA` and follow the
 instructions.
diff --git a/pyproject.toml b/pyproject.toml
index b8ad66f8135b545cce2ee196f9a3db7d4c1e033e..045ef0f53bd2d9e5ac75c004c5a767edf8ae2bdf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -70,7 +70,7 @@ django-ipware = "^4.0"
 django-impersonate = "^1.4"
 psycopg2 = "^2.8"
 django_select2 = "^8.0"
-django-two-factor-auth = { version = "^1.14.0", extras = [ "yubikey", "phonenumbers", "call", "sms" ] }
+django-two-factor-auth = { version = "^1.15.1", extras = [ "yubikey", "phonenumbers", "call", "sms", "webauthn" ] }
 django-yarnpkg = "^6.0"
 django-material = "^1.6.0"
 django-dynamic-preferences = "^1.11"