about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorsantiagorodriguez96 <46354312+santiagorodriguez96@users.noreply.github.com>2020-08-24 11:46:27 -0300
committerGitHub <noreply@github.com>2020-08-24 16:46:27 +0200
commite8d41bc2fe9418613cdc118c8674fc5fe7856685 (patch)
tree138248b982d3bb9f8cbb4ecf65a3d7b0bfd80312 /app
parenta3ec9af9b009d4548e3e8f7369213883212a922a (diff)
Add WebAuthn as an alternative 2FA method (#14466)
* feat: add possibility of adding WebAuthn security keys to use as 2FA

This adds a basic UI for enabling WebAuthn 2FA. We did a little refactor
to the Settings page for editing the 2FA methods – now it will list the
methods that are available to the user (TOTP and WebAuthn) and from
there they'll be able to add or remove any of them.
Also, it's worth mentioning that for enabling WebAuthn it's required to
have TOTP enabled, so the first time that you go to the 2FA Settings
page, you'll be asked to set it up.
This work was inspired by the one donde by Github in their platform, and
despite it could be approached in different ways, we decided to go with
this one given that we feel that this gives a great UX.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: add request for WebAuthn as second factor at login if enabled

This commits adds the feature for using WebAuthn as a second factor for
login when enabled.
If users have WebAuthn enabled, now a page requesting for the use of a
WebAuthn credential for log in will appear, although a link redirecting
to the old page for logging in using a two-factor code will also be
present.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: add possibility of deleting WebAuthn Credentials

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: disable WebAuthn when an Admin disables 2FA for a user

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* feat: remove ability to disable TOTP leaving only WebAuthn as 2FA

Following examples form other platforms like Github, we decided to make
Webauthn 2FA secondary to 2FA with TOTP, so that we removed the
possibility of removing TOTP authentication only, leaving users with
just WEbAuthn as 2FA. Instead, users will have to click on 'Disable 2FA'
in order to remove second factor auth.
The reason for WebAuthn being secondary to TOPT is that in that way,
users will still be able to log in using their code from their phone's
application if they don't have their security keys with them – or maybe
even lost them.

* We had to change a little the flow for setting up TOTP, given that now
  it's possible to setting up again if you already had TOTP, in order to
  let users modify their authenticator app – given that now it's not
  possible for them to disable TOTP and set it up again with another
  authenticator app.
  So, basically, now instead of storing the new `otp_secret` in the
  user, we store it in the session until the process of set up is
  finished.
  This was because, as it was before, when users clicked on 'Edit' in
  the new two-factor methods lists page, but then went back without
  finishing the flow, their `otp_secret` had been changed therefore
  invalidating their previous authenticator app, making them unable to
  log in again using TOTP.

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>

* refactor: fix eslint errors

The PR build was failing given that linting returning some errors.
This commit attempts to fix them.

* refactor: normalize i18n translations

The build was failing given that i18n translations files were not
normalized.
This commits fixes that.

* refactor: avoid having the webauthn gem locked to a specific version

* refactor: use symbols for routes without '/'

* refactor: avoid sending webauthn disabled email when 2FA is disabled

When an admins disable 2FA for users, we were sending two mails
to them, one notifying that 2FA was disabled and the other to notify
that WebAuthn was disabled.
As the second one is redundant since the first email includes it, we can
remove it and send just one email to users.

* refactor: avoid creating new env variable for webauthn_origin config

* refactor: improve flash error messages for webauthn pages

Co-authored-by: Facundo Padula <facundo.padula@cedarcode.com>
Diffstat (limited to 'app')
-rw-r--r--app/controllers/auth/sessions_controller.rb18
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb45
-rw-r--r--app/controllers/settings/two_factor_authentication/confirmations_controller.rb14
-rw-r--r--app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb42
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb103
-rw-r--r--app/controllers/settings/two_factor_authentication_methods_controller.rb30
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb53
-rw-r--r--app/javascript/packs/two_factor_authentication.js118
-rw-r--r--app/javascript/styles/mastodon/forms.scss18
-rw-r--r--app/mailers/user_mailer.rb46
-rw-r--r--app/models/user.rb18
-rw-r--r--app/models/webauthn_credential.rb22
-rw-r--r--app/views/auth/sessions/two_factor.html.haml13
-rw-r--r--app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml18
-rw-r--r--app/views/auth/sessions/two_factor/_webauthn_form.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml10
-rw-r--r--app/views/settings/two_factor_authentication/otp_authentication/show.html.haml9
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml16
-rw-r--r--app/views/settings/two_factor_authentication_methods/index.html.haml41
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml36
-rw-r--r--app/views/user_mailer/webauthn_credential_added.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_added.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_disabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_disabled.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_enabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_enabled.text.erb7
29 files changed, 793 insertions, 113 deletions
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 1fd755334..c1ea702ad 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -37,6 +37,22 @@ class Auth::SessionsController < Devise::SessionsController
     store_location_for(:user, tmp_stored_location) if continue_after?
   end
 
+  def webauthn_options
+    user = find_user
+
+    if user.webauthn_enabled?
+      options_for_get = WebAuthn::Credential.options_for_get(
+        allow: user.webauthn_credentials.pluck(:external_id)
+      )
+
+      session[:webauthn_challenge] = options_for_get.challenge
+
+      render json: options_for_get, status: :ok
+    else
+      render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized
+    end
+  end
+
   protected
 
   def find_user
@@ -51,7 +67,7 @@ class Auth::SessionsController < Devise::SessionsController
   end
 
   def user_params
-    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
+    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
   end
 
   def after_sign_in_path_for(resource)
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index daafe56f4..8a2a86a02 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
   end
 
   def two_factor_enabled?
-    find_user&.otp_required_for_login?
+    find_user&.two_factor_enabled?
+  end
+
+  def valid_webauthn_credential?(user, webauthn_credential)
+    user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id)
+
+    begin
+      webauthn_credential.verify(
+        session[:webauthn_challenge],
+        public_key: user_credential.public_key,
+        sign_count: user_credential.sign_count
+      )
+
+      user_credential.update!(sign_count: webauthn_credential.sign_count)
+    rescue WebAuthn::Error
+      false
+    end
   end
 
   def valid_otp_attempt?(user)
@@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
   def authenticate_with_two_factor
     user = self.resource = find_user
 
-    if user_params[:otp_attempt].present? && session[:attempt_user_id]
-      authenticate_with_two_factor_attempt(user)
+    if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
+      authenticate_with_two_factor_via_webauthn(user)
+    elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
+      authenticate_with_two_factor_via_otp(user)
     elsif user.present? && user.external_or_valid_password?(user_params[:password])
       prompt_for_two_factor(user)
     end
   end
 
-  def authenticate_with_two_factor_attempt(user)
+  def authenticate_with_two_factor_via_webauthn(user)
+    webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
+
+    if valid_webauthn_credential?(user, webauthn_credential)
+      session.delete(:attempt_user_id)
+      remember_me(user)
+      sign_in(user)
+      render json: { redirect_path: root_path }, status: :ok
+    else
+      render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
+    end
+  end
+
+  def authenticate_with_two_factor_via_otp(user)
     if valid_otp_attempt?(user)
       session.delete(:attempt_user_id)
       remember_me(user)
@@ -43,6 +74,12 @@ module TwoFactorAuthenticationConcern
     set_locale do
       session[:attempt_user_id] = user.id
       @body_classes = 'lighter'
+      @webauthn_enabled = user.webauthn_enabled?
+      @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
+                       'webauthn'
+                     else
+                       'totp'
+                     end
       render :two_factor
     end
   end
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index ef4df3339..9f23011a7 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -18,18 +18,21 @@ module Settings
       end
 
       def create
-        if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt])
+        if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret])
           flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
 
           current_user.otp_required_for_login = true
+          current_user.otp_secret = session[:new_otp_secret]
           @recovery_codes = current_user.generate_otp_backup_codes!
           current_user.save!
 
           UserMailer.two_factor_enabled(current_user).deliver_later!
 
+          session.delete(:new_otp_secret)
+
           render 'settings/two_factor_authentication/recovery_codes/index'
         else
-          flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
+          flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
           prepare_two_factor_form
           render :new
         end
@@ -43,12 +46,15 @@ module Settings
 
       def prepare_two_factor_form
         @confirmation = Form::TwoFactorConfirmation.new
-        @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
+        @new_otp_secret = session[:new_otp_secret]
+        @provision_url = current_user.otp_provisioning_uri(current_user.email,
+                                                           otp_secret: @new_otp_secret,
+                                                           issuer: Rails.configuration.x.local_domain)
         @qrcode = RQRCode::QRCode.new(@provision_url)
       end
 
       def ensure_otp_secret
-        redirect_to settings_two_factor_authentication_path unless current_user.otp_secret
+        redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank?
       end
     end
   end
diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
new file mode 100644
index 000000000..6836f7ef6
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Settings
+  module TwoFactorAuthentication
+    class OtpAuthenticationController < BaseController
+      include ChallengableConcern
+
+      layout 'admin'
+
+      before_action :authenticate_user!
+      before_action :verify_otp_not_enabled, only: [:show]
+      before_action :require_challenge!, only: [:create]
+
+      skip_before_action :require_functional!
+
+      def show
+        @confirmation = Form::TwoFactorConfirmation.new
+      end
+
+      def create
+        session[:new_otp_secret] = User.generate_otp_secret(32)
+
+        redirect_to new_settings_two_factor_authentication_confirmation_path
+      end
+
+      private
+
+      def confirmation_params
+        params.require(:form_two_factor_confirmation).permit(:otp_attempt)
+      end
+
+      def verify_otp_not_enabled
+        redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled?
+      end
+
+      def acceptable_code?
+        current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
+          current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
+      end
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
new file mode 100644
index 000000000..a19c604f3
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Settings
+  module TwoFactorAuthentication
+    class WebauthnCredentialsController < BaseController
+      layout 'admin'
+
+      before_action :authenticate_user!
+      before_action :require_otp_enabled
+      before_action :require_webauthn_enabled, only: [:index, :destroy]
+
+      def new; end
+
+      def index; end
+
+      def options
+        current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id
+
+        options_for_create = WebAuthn::Credential.options_for_create(
+          user: {
+            name: current_user.account.username,
+            display_name: current_user.account.username,
+            id: current_user.webauthn_id,
+          },
+          exclude: current_user.webauthn_credentials.pluck(:external_id)
+        )
+
+        session[:webauthn_challenge] = options_for_create.challenge
+
+        render json: options_for_create, status: :ok
+      end
+
+      def create
+        webauthn_credential = WebAuthn::Credential.from_create(params[:credential])
+
+        if webauthn_credential.verify(session[:webauthn_challenge])
+          user_credential = current_user.webauthn_credentials.build(
+            external_id: webauthn_credential.id,
+            public_key: webauthn_credential.public_key,
+            nickname: params[:nickname],
+            sign_count: webauthn_credential.sign_count
+          )
+
+          if user_credential.save
+            flash[:success] = I18n.t('webauthn_credentials.create.success')
+            status = :ok
+
+            if current_user.webauthn_credentials.size == 1
+              UserMailer.webauthn_enabled(current_user).deliver_later!
+            else
+              UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later!
+            end
+          else
+            flash[:error] = I18n.t('webauthn_credentials.create.error')
+            status = :internal_server_error
+          end
+        else
+          flash[:error] = t('webauthn_credentials.create.error')
+          status = :unauthorized
+        end
+
+        render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status
+      end
+
+      def destroy
+        credential = current_user.webauthn_credentials.find_by(id: params[:id])
+        if credential
+          credential.destroy
+          if credential.destroyed?
+            flash[:success] = I18n.t('webauthn_credentials.destroy.success')
+
+            if current_user.webauthn_credentials.empty?
+              UserMailer.webauthn_disabled(current_user).deliver_later!
+            else
+              UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later!
+            end
+          else
+            flash[:error] = I18n.t('webauthn_credentials.destroy.error')
+          end
+        else
+          flash[:error] = I18n.t('webauthn_credentials.destroy.error')
+        end
+        redirect_to settings_two_factor_authentication_methods_path
+      end
+
+      private
+
+      def require_otp_enabled
+        unless current_user.otp_enabled?
+          flash[:error] = t('webauthn_credentials.otp_required')
+          redirect_to settings_two_factor_authentication_methods_path
+        end
+      end
+
+      def require_webauthn_enabled
+        unless current_user.webauthn_enabled?
+          flash[:error] = t('webauthn_credentials.not_enabled')
+          redirect_to settings_two_factor_authentication_methods_path
+        end
+      end
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb
new file mode 100644
index 000000000..224d3a45c
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Settings
+  class TwoFactorAuthenticationMethodsController < BaseController
+    include ChallengableConcern
+
+    layout 'admin'
+
+    before_action :authenticate_user!
+    before_action :require_challenge!, only: :disable
+    before_action :require_otp_enabled
+
+    skip_before_action :require_functional!
+
+    def index; end
+
+    def disable
+      current_user.disable_two_factor!
+      UserMailer.two_factor_disabled(current_user).deliver_later!
+
+      redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') }
+    end
+
+    private
+
+    def require_otp_enabled
+      redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
deleted file mode 100644
index 9118a7933..000000000
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Settings
-  class TwoFactorAuthenticationsController < BaseController
-    include ChallengableConcern
-
-    layout 'admin'
-
-    before_action :authenticate_user!
-    before_action :verify_otp_required, only: [:create]
-    before_action :require_challenge!, only: [:create]
-
-    skip_before_action :require_functional!
-
-    def show
-      @confirmation = Form::TwoFactorConfirmation.new
-    end
-
-    def create
-      current_user.otp_secret = User.generate_otp_secret(32)
-      current_user.save!
-      redirect_to new_settings_two_factor_authentication_confirmation_path
-    end
-
-    def destroy
-      if acceptable_code?
-        current_user.otp_required_for_login = false
-        current_user.save!
-        UserMailer.two_factor_disabled(current_user).deliver_later!
-        redirect_to settings_two_factor_authentication_path
-      else
-        flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
-        @confirmation = Form::TwoFactorConfirmation.new
-        render :show
-      end
-    end
-
-    private
-
-    def confirmation_params
-      params.require(:form_two_factor_confirmation).permit(:otp_attempt)
-    end
-
-    def verify_otp_required
-      redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
-    end
-
-    def acceptable_code?
-      current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
-        current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
-    end
-  end
-end
diff --git a/app/javascript/packs/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js
new file mode 100644
index 000000000..dde06be8c
--- /dev/null
+++ b/app/javascript/packs/two_factor_authentication.js
@@ -0,0 +1,118 @@
+import axios from 'axios';
+import * as WebAuthnJSON from '@github/webauthn-json';
+import ready from '../mastodon/ready';
+import 'regenerator-runtime/runtime';
+
+function getCSRFToken() {
+  var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
+  if (CSRFSelector) {
+    return CSRFSelector.getAttribute('content');
+  } else {
+    return null;
+  }
+}
+
+function hideFlashMessages() {
+  Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
+    flashMessage.classList.add('hidden');
+  });
+}
+
+function callback(url, body) {
+  axios.post(url, JSON.stringify(body), {
+    headers: {
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+      'X-CSRF-Token': getCSRFToken(),
+    },
+    credentials: 'same-origin',
+  }).then(function(response) {
+    window.location.replace(response.data.redirect_path);
+  }).catch(function(error) {
+    if (error.response.status === 422) {
+      const errorMessage = document.getElementById('security-key-error-message');
+      errorMessage.classList.remove('hidden');
+      console.error(error.response.data.error);
+    } else {
+      console.error(error);
+    }
+  });
+}
+
+ready(() => {
+  if (!WebAuthnJSON.supported()) {
+    const unsupported_browser_message = document.getElementById('unsupported-browser-message');
+    if (unsupported_browser_message) {
+      unsupported_browser_message.classList.remove('hidden');
+      document.querySelector('.btn.js-webauthn').disabled = true;
+    }
+  }
+
+
+  const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
+  if (webAuthnCredentialRegistrationForm) {
+    webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
+      if (nickname.value) {
+        axios.get('/settings/security_keys/options')
+          .then((response) => {
+            const credentialOptions = response.data;
+
+            WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
+              var params = { 'credential': credential, 'nickname': nickname.value };
+              callback('/settings/security_keys', params);
+            }).catch((error) => {
+              const errorMessage = document.getElementById('security-key-error-message');
+              errorMessage.classList.remove('hidden');
+              console.error(error);
+            });
+          }).catch((error) => {
+            console.error(error.response.data.error);
+          });
+      } else {
+        nickname.focus();
+      }
+    });
+  }
+
+  const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
+  if (webAuthnCredentialAuthenticationForm) {
+    webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      axios.get('sessions/security_key_options')
+        .then((response) => {
+          const credentialOptions = response.data;
+
+          WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
+            var params = { 'user': { 'credential': credential } };
+            callback('sign_in', params);
+          }).catch((error) => {
+            const errorMessage = document.getElementById('security-key-error-message');
+            errorMessage.classList.remove('hidden');
+            console.error(error);
+          });
+        }).catch((error) => {
+          console.error(error.response.data.error);
+        });
+    });
+
+    const otpAuthenticationForm = document.getElementById('otp-authentication-form');
+
+    const linkToOtp = document.getElementById('link-to-otp');
+    linkToOtp.addEventListener('click', () => {
+      webAuthnCredentialAuthenticationForm.classList.add('hidden');
+      otpAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+
+    const linkToWebAuthn = document.getElementById('link-to-webauthn');
+    linkToWebAuthn.addEventListener('click', () => {
+      otpAuthenticationForm.classList.add('hidden');
+      webAuthnCredentialAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+  }
+});
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index a6df51f95..a54a5fded 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -12,6 +12,10 @@ code {
 }
 
 .simple_form {
+  &.hidden {
+    display: none;
+  }
+
   .input {
     margin-bottom: 15px;
     overflow: hidden;
@@ -100,6 +104,14 @@ code {
     }
   }
 
+  .title {
+    color: #d9e1e8;
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 400;
+    margin-bottom: 30px;
+  }
+
   .hint {
     color: $darker-text-color;
 
@@ -142,7 +154,7 @@ code {
     }
   }
 
-  .otp-hint {
+  .authentication-hint {
     margin-bottom: 25px;
   }
 
@@ -592,6 +604,10 @@ code {
     color: $error-value-color;
   }
 
+  &.hidden {
+    display: none;
+  }
+
   a {
     display: inline-block;
     color: $darker-text-color;
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 2cd58e60a..b55768551 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
     end
   end
 
+  def webauthn_enabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
+    end
+  end
+
+  def webauthn_disabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
+    end
+  end
+
+  def webauthn_credential_added(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
+    end
+  end
+
+  def webauthn_credential_deleted(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
+    end
+  end
+
   def welcome(user)
     @resource = user
     @instance = Rails.configuration.x.local_domain
diff --git a/app/models/user.rb b/app/models/user.rb
index 306e2d435..7e3b37475 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -40,6 +40,7 @@
 #  approved                  :boolean          default(TRUE), not null
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
+#  webauthn_id               :string
 #
 
 class User < ApplicationRecord
@@ -77,6 +78,7 @@ class User < ApplicationRecord
   has_many :backups, inverse_of: :user
   has_many :invites, inverse_of: :user
   has_many :markers, inverse_of: :user, dependent: :destroy
+  has_many :webauthn_credentials, dependent: :destroy
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@@ -197,9 +199,25 @@ class User < ApplicationRecord
     prepare_returning_user!
   end
 
+  def otp_enabled?
+    otp_required_for_login
+  end
+
+  def webauthn_enabled?
+    webauthn_credentials.any?
+  end
+
+  def two_factor_enabled?
+    otp_required_for_login? || webauthn_credentials.any?
+  end
+
   def disable_two_factor!
     self.otp_required_for_login = false
+    self.otp_secret = nil
     otp_backup_codes&.clear
+
+    webauthn_credentials.destroy_all if webauthn_enabled?
+
     save!
   end
 
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
new file mode 100644
index 000000000..4129ce539
--- /dev/null
+++ b/app/models/webauthn_credential.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: webauthn_credentials
+#
+#  id          :bigint(8)        not null, primary key
+#  external_id :string           not null
+#  public_key  :string           not null
+#  nickname    :string           not null
+#  sign_count  :bigint(8)        default(0), not null
+#  user_id     :bigint(8)
+#  created_at  :datetime         not null
+#  updated_at  :datetime         not null
+#
+
+class WebauthnCredential < ApplicationRecord
+  validates :external_id, :public_key, :nickname, :sign_count, presence: true
+  validates :external_id, uniqueness: true
+  validates :nickname, uniqueness: { scope: :user_id }
+  validates :sign_count,
+            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+end
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index b2e36f6bc..f2f6fe19d 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -1,14 +1,9 @@
 - content_for :page_title do
   = t('auth.login')
 
-= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
-  %p.hint.otp-hint= t('simple_form.hints.sessions.otp')
+=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
 
-  .fields-group
-    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+- if @webauthn_enabled
+  = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
 
-  .actions
-    = f.button :button, t('auth.login'), type: :submit
-
-  - if Setting.site_contact_email.present?
-    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+= render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }
diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
new file mode 100644
index 000000000..ab2d48c0a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
@@ -0,0 +1,18 @@
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
+
+  .fields-group
+    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+
+  .actions
+    = f.button :button, t('auth.login'), type: :submit
+
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+
+  - if @webauthn_enabled
+    .form-footer
+      = link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn')
diff --git a/app/views/auth/sessions/two_factor/_webauthn_form.html.haml b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
new file mode 100644
index 000000000..32ed1294a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
@@ -0,0 +1,17 @@
+%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %h3.title= t('simple_form.title.sessions.webauthn')
+  %p.hint= t('simple_form.hints.sessions.webauthn')
+
+  .actions
+    = f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit
+
+  .form-footer
+    %p= t('auth.dont_have_your_security_key')
+    = link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp')
diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
index 86cf1f695..671237db5 100644
--- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml
+++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
@@ -2,17 +2,17 @@
   = t('settings.two_factor_authentication')
 
 = simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f|
-  %p.hint= t('two_factor_authentication.instructions_html')
+  %p.hint= t('otp_authentication.instructions_html')
 
   .qr-wrapper
     .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
 
     .qr-alternative
-      %p.hint= t('two_factor_authentication.manual_instructions')
-      %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ')
+      %p.hint= t('otp_authentication.manual_instructions')
+      %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
 
   .fields-group
-    = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
+    = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
 
   .actions
-    = f.button :button, t('two_factor_authentication.enable'), type: :submit
+    = f.button :button, t('otp_authentication.enable'), type: :submit
diff --git a/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
new file mode 100644
index 000000000..d069ba12a
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
@@ -0,0 +1,9 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+.simple_form
+  %p.hint= t('otp_authentication.description_html')
+
+  %hr.spacer/
+
+  = link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
new file mode 100644
index 000000000..0dfd94ab9
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+.table-wrapper
+  %table.table
+    %tbody
+      - current_user.webauthn_credentials.each do |credential|
+        %tr
+          %td= credential.nickname
+          %td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name))
+          %td
+            = table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') }
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
new file mode 100644
index 000000000..0b23bb689
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
@@ -0,0 +1,16 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+= simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f|
+  %p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+  %p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+  %p.hint= t('webauthn_credentials.description_html')
+
+  .fields_group
+    = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
+
+  .actions
+    = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
+
+= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
diff --git a/app/views/settings/two_factor_authentication_methods/index.html.haml b/app/views/settings/two_factor_authentication_methods/index.html.haml
new file mode 100644
index 000000000..315443e6d
--- /dev/null
+++ b/app/views/settings/two_factor_authentication_methods/index.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+- content_for :heading_actions do
+  = link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
+
+%p.hint
+  %span.positive-hint
+    = fa_icon 'check'
+    = ' '
+    = t 'two_factor_authentication.enabled'
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('two_factor_authentication.methods')
+        %th
+    %tbody
+      %tr
+        %td= t('two_factor_authentication.otp')
+        %td
+          = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post
+      %tr
+        %td= t('two_factor_authentication.webauthn')
+        - if current_user.webauthn_enabled?
+          %td
+            = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get
+        - else
+          %td
+            = table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get
+
+%hr.spacer/
+
+%h3= t('two_factor_authentication.recovery_codes')
+%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml
deleted file mode 100644
index f1eecd000..000000000
--- a/app/views/settings/two_factor_authentications/show.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- content_for :page_title do
-  = t('settings.two_factor_authentication')
-
-- if current_user.otp_required_for_login
-  %p.hint
-    %span.positive-hint
-      = fa_icon 'check'
-      = ' '
-      = t 'two_factor_authentication.enabled'
-
-  %hr.spacer/
-
-  = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
-    .fields-group
-      = f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
-
-    .actions
-      = f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
-
-  %hr.spacer/
-
-  %h3= t('two_factor_authentication.recovery_codes')
-  %p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
-
-  %hr.spacer/
-
-  .simple_form
-    = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
-
-- else
-  .simple_form
-    %p.hint= t('two_factor_authentication.description_html')
-
-    %hr.spacer/
-
-    = link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/user_mailer/webauthn_credential_added.html.haml b/app/views/user_mailer/webauthn_credential_added.html.haml
new file mode 100644
index 000000000..81de84b56
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.added.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_added.text.erb b/app/views/user_mailer/webauthn_credential_added.text.erb
new file mode 100644
index 000000000..4319dddbf
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.two_factor_enabled.title' %>
+
+===
+
+<%= t 'devise.mailer.two_factor_enabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_credential_deleted.html.haml b/app/views/user_mailer/webauthn_credential_deleted.html.haml
new file mode 100644
index 000000000..7b47f0c88
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.deleted.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_deleted.text.erb b/app/views/user_mailer/webauthn_credential_deleted.text.erb
new file mode 100644
index 000000000..53e5bc78c
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_disabled.html.haml b/app/views/user_mailer/webauthn_disabled.html.haml
new file mode 100644
index 000000000..81a2a7954
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_disabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_disabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_disabled.text.erb b/app/views/user_mailer/webauthn_disabled.text.erb
new file mode 100644
index 000000000..962df77ca
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_disabled.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_disabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_enabled.html.haml b/app/views/user_mailer/webauthn_enabled.html.haml
new file mode 100644
index 000000000..f08e764e8
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_enabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_enabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb
new file mode 100644
index 000000000..4c233fefb
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credentia.added.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
+
+=> <%= edit_user_registration_url %>