diff --git a/.gitignore b/.gitignore
index a6b65dcb966ba1d7fe7fcf774d04e05e0dc419ba..aebed19bf22e0b7ba835ffcbc1aafc977b6faab9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,3 +147,7 @@ Sessionx.vim
 tags
 # Persistent undo
 [._]*.un~
+
+# Auto-generated development key/certificate
+devcert.crt
+devcert.key
diff --git a/README.md b/README.md
index 593db3ccb77fbeadf30d9903696074dc273f21b9..18ee6ba02ae88a6c4492e3ae0be81b7264f54905 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ A web service to manage LDAP users, groups and permissions.
 - python3-ldap3
 - python3-flask
 - python3-flask-sqlalchemy
+- python3-qrcode
+- python3-fido2 (version 0.5.0, optional)
 - git (cli utility, musst be in path)
 
 ## development
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..347cf2a9073124b259908bfa7bb8f851ce0af5ef
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+# Versions from Debian Buster
+ldap3==2.4.1
+flask==1.0.2
+Flask-SQLAlchemy==2.1
+qrcode==6.1
+fido2==0.5.0
+Flask-OAuthlib==0.9.5
diff --git a/run.py b/run.py
index 461c5deace37dcc8e97d615530f495cfa02505d2..b8ccfa5b4ba817566cc5759c8850a03452e73b5f 100755
--- a/run.py
+++ b/run.py
@@ -1,8 +1,15 @@
 #!/usr/bin/env python3
+from werkzeug.serving import make_ssl_devcert
+
 from uffd import *
 
 if __name__ == '__main__':
 	app = create_app()
 	init_db(app)
 	print(app.url_map)
-	app.run(threaded=True, debug=True)
+	if not os.path.exists('devcert.crt') or not os.path.exists('devcert.key'):
+		make_ssl_devcert('devcert')
+	# WebAuthn requires https and a hostname (not just an IP address). If you
+	# don't want to test U2F/FIDO2 device registration/authorization, you can
+	# safely remove `host` and `ssl_context`.
+	app.run(threaded=True, debug=True, host='localhost', ssl_context=('devcert.crt', 'devcert.key'))
diff --git a/uffd/__init__.py b/uffd/__init__.py
index de5fbde633d2bd03579d677b59feef0b78b9dd5d..7280d0ba62c68d9a7ad02307f4e4e996045fae07 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -39,10 +39,10 @@ def create_app(test_config=None):
 
 	db.init_app(app)
 	# pylint: disable=C0415
-	from uffd import user, selfservice, role, mail, session, csrf, ldap
+	from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa
 	# pylint: enable=C0415
 
-	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp:
+	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp:
 		app.register_blueprint(i)
 
 	@app.route("/")
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 3cd276092cec9899e16b49ebf1a1117c2db27af3..0cf302b0447cb4f58add03f4aba1ee7b033310da 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -24,6 +24,10 @@ MAIL_USE_STARTTLS=True
 MAIL_FROM_ADDRESS='foo@bar.com'
 MAIL_LDAP_OBJECTCLASSES=["top", "postfixVirtual"]
 
+#MFA_ICON_URL = 'https://example.com/logo.png'
+#MFA_RP_ID = 'example.com' # If unset, hostname from current request is used
+MFA_RP_NAME = 'Uffd Test Service' # Service name passed to U2F/FIDO2 authenticators
+
 ROLES_BASEROLES=['base']
 
 SQLALCHEMY_TRACK_MODIFICATIONS=False
diff --git a/uffd/mfa/__init__.py b/uffd/mfa/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..656390049779db11f3fbd9a16498218b18982068
--- /dev/null
+++ b/uffd/mfa/__init__.py
@@ -0,0 +1,3 @@
+from .views import bp as _bp
+
+bp = [_bp]
diff --git a/uffd/mfa/models.py b/uffd/mfa/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..fccae0e8cbe025495e5a3213bfdf87feb0e7082c
--- /dev/null
+++ b/uffd/mfa/models.py
@@ -0,0 +1,149 @@
+import enum
+import datetime
+import secrets
+# imports for totp
+import time
+import struct
+import hmac
+import hashlib
+import base64
+import urllib.parse
+# imports for recovery codes
+import crypt
+
+from flask import request, current_app
+from sqlalchemy import Column, Integer, Enum, String, DateTime, Text
+
+from uffd.database import db
+from uffd.user.models import User
+
+class MFAType(enum.Enum):
+	RECOVERY_CODE = 0
+	TOTP = 1
+	WEBAUTHN = 2
+
+class MFAMethod(db.Model):
+	__tablename__ = 'mfa_method'
+	id = Column(Integer(), primary_key=True, autoincrement=True)
+	type = Column(Enum(MFAType))
+	created = Column(DateTime())
+	name = Column(String(128))
+	dn = Column(String(128))
+
+	__mapper_args__ = {
+		'polymorphic_on': type,
+	}
+
+	def __init__(self, user, name=None):
+		self.user = user
+		self.name = name
+		self.created = datetime.datetime.now()
+
+	@property
+	def user(self):
+		return User.from_ldap_dn(self.dn)
+
+	@user.setter
+	def user(self, new_user):
+		self.dn = new_user.dn
+
+class RecoveryCodeMethod(MFAMethod):
+	code_salt = Column('recovery_salt', String(64))
+	code_hash = Column('recovery_hash', String(256))
+
+	__mapper_args__ = {
+		'polymorphic_identity': MFAType.RECOVERY_CODE
+	}
+
+	def __init__(self, user):
+		super().__init__(user, None)
+		# The code attribute is only available in newly created objects as only
+		# it's hash is stored in the database
+		self.code = secrets.token_hex(8).replace(' ', '').lower()
+		self.code_hash = crypt.crypt(self.code)
+
+	def verify(self, code):
+		code = code.replace(' ', '').lower()
+		return crypt.crypt(code, self.code_hash) == self.code_hash
+
+def _hotp(counter, key, digits=6):
+	'''Generates HMAC-based one-time password according to RFC4226
+
+	:param counter: Positive integer smaller than 2**64
+	:param key: Bytes object of arbitrary length (should be at least 160 bits)
+	:param digits: Length of resulting value (integer between 1 and 9, minimum of 6 is recommended)
+
+	:returns: String object representing human-readable HOTP value'''
+	msg = struct.pack('>Q', counter)
+	digest = hmac.new(key, msg=msg, digestmod=hashlib.sha1).digest()
+	offset = digest[19] & 0x0f
+	snum = struct.unpack('>L', digest[offset:offset+4])[0] & 0x7fffffff
+	return str(snum % (10**digits)).zfill(digits)
+
+class TOTPMethod(MFAMethod):
+	key = Column('totp_key', String(64))
+
+	__mapper_args__ = {
+		'polymorphic_identity': MFAType.TOTP
+	}
+
+	def __init__(self, user, name=None, key=None):
+		super().__init__(user, name)
+		if key is None:
+			key = base64.b32encode(secrets.token_bytes(16)).rstrip(b'=').decode()
+		self.key = key
+
+	@property
+	def raw_key(self):
+		tmp = self.key + '='*(8 - (len(self.key) % 8))
+		return base64.b32decode(tmp.encode())
+
+	@property
+	def issuer(self):
+		return urllib.parse.urlsplit(request.url).hostname
+
+	@property
+	def accountname(self):
+		return self.user.loginname
+
+	@property
+	def key_uri(self):
+		issuer = urllib.parse.quote(self.issuer)
+		accountname = urllib.parse.quote(self.accountname)
+		params = {'secret': self.key, 'issuer': issuer}
+		if 'MFA_ICON_URL' in current_app.config:
+			params['image'] = current_app.config['MFA_ICON_URL']
+		return 'otpauth://totp/%s:%s?%s'%(issuer, accountname, urllib.parse.urlencode(params))
+
+	def verify(self, code):
+		'''Verify that code is valid
+
+		Code verification must be rate-limited!
+
+		:param code: String of digits (as entered by the user)
+
+		:returns: True if code is valid, False otherwise'''
+		counter = int(time.time()/30)
+		if _hotp(counter-1, self.raw_key) == code or _hotp(counter, self.raw_key) == code:
+			return True
+		return False
+
+class WebauthnMethod(MFAMethod):
+	_cred = Column('webauthn_cred', Text())
+
+	__mapper_args__ = {
+		'polymorphic_identity': MFAType.WEBAUTHN
+	}
+
+	def __init__(self, user, cred, name=None):
+		super().__init__(user, name)
+		self.cred = cred
+
+	@property
+	def cred(self):
+		from fido2.ctap2 import AttestedCredentialData #pylint: disable=import-outside-toplevel
+		return AttestedCredentialData(base64.b64decode(self._cred))
+
+	@cred.setter
+	def cred(self, newcred):
+		self._cred = base64.b64encode(bytes(newcred))
diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/auth.html
new file mode 100644
index 0000000000000000000000000000000000000000..831bc87a74ed93418ee6ec6139765cf0ac656c7b
--- /dev/null
+++ b/uffd/mfa/templates/auth.html
@@ -0,0 +1,126 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<form action="{{ url_for("mfa.auth_finish", ref=ref) }}" method="POST">
+<div class="row mt-2 justify-content-center">
+	<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
+		<div class="text-center">
+			<img src="{{ url_for("static", filename="chaosknoten.png") }}" class="col-lg-8 col-md-12" >
+		</div>
+		<div class="col-12 mb-3">
+			<h2 class="text-center">Two-Factor Authentication</h2>
+		</div>
+		{% if webauthn_methods %}
+		<noscript>
+			<div class="form-group col-12">
+				<div id="webauthn-nojs" class="alert alert-warning" role="alert">Enable javascript for authentication with U2F/FIDO2 devices</div>
+			</div>
+		</noscript>
+		<div id="webauthn-unsupported" class="form-group col-12 d-none">
+			<div class="alert alert-warning" role="alert">Authentication with U2F/FIDO2 devices is not supported by your browser</div>
+		</div>
+		<div class="form-group col-12 d-none webauthn-group">
+			<div id="webauthn-alert" class="alert alert-warning d-none" role="alert"></div>
+			<button type="button" id="webauthn-btn" class="btn btn-primary btn-block">
+				<span id="webauthn-spinner" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
+				<span id="webauthn-btn-text">Authenticate with U2F/FIDO2 device</span>
+			</button>
+		</div>
+		<div class="text-center text-muted d-none webauthn-group mb-3">- or -</div>
+		{% endif %}
+		<div class="form-group col-12 mb-2">
+			<input type="text" class="form-control" id="mfa-code" name="code" required="required" placeholder="Code from your authenticator app or recovery code" autocomplete="off" autofocus>
+		</div>
+		<div class="form-group col-12">
+			<button type="submit" class="btn btn-primary btn-block">Verify</button>
+		</div>
+	</div>
+</div>
+</form>
+
+{% if webauthn_supported and webauthn_methods %}
+<script src="{{ url_for('static', filename="cbor.js") }}"></script>
+<script>
+function begin_webauthn() {
+	$('#webauthn-alert').addClass('d-none');
+	$('#webauthn-spinner').removeClass('d-none');
+	$('#webauthn-btn-text').text('Fetching credential data');
+	$('#webauthn-btn').prop('disabled', true);
+	fetch({{ url_for('mfa.auth_webauthn_begin')|tojson }}, {
+		method: 'POST',
+	}).then(function(response) {
+		if (response.ok) {
+			return response.arrayBuffer();
+		} else if (response.status == 403) {
+			window.location = {{ request.url|tojson }}; /* reload */
+			throw new Error('Session timed out');
+		} else if (response.status == 404) {
+			throw new Error('You have not registered any U2F/FIDO2 devices for your account');
+		} else {
+			throw new Error('Server error');
+		}
+	}).then(CBOR.decode).then(function(options) {
+		$('#webauthn-btn-text').text('Waiting for response from your device');
+		return navigator.credentials.get(options);
+	}).then(function(assertion) {
+		$('#webauthn-btn-text').text('Verifing response');
+		return fetch({{ url_for('mfa.auth_webauthn_complete')|tojson }}, {
+			method: 'POST',
+			headers: {'Content-Type': 'application/cbor'},
+			body: CBOR.encode({
+				"credentialId": new Uint8Array(assertion.rawId),
+				"authenticatorData": new Uint8Array(assertion.response.authenticatorData),
+				"clientDataJSON": new Uint8Array(assertion.response.clientDataJSON),
+				"signature": new Uint8Array(assertion.response.signature)
+			})
+		})
+	}).then(function(response) {
+		if (response.ok) {
+			$('#webauthn-spinner').addClass('d-none');
+			$('#webauthn-btn-text').text('Success, redirecting');
+			window.location = {{ (ref or url_for('index'))|tojson }};
+		} else if (response.status == 403) {
+			window.location = {{ request.url|tojson }}; /* reload */
+			throw new Error('Session timed out');
+		} else {
+			throw new Error('Response from authenticator rejected');
+		}
+	}).catch(function(err) {
+		console.log(err);
+		/* various webauthn errors */
+		if (err.name == 'NotAllowedError')
+			$('#webauthn-alert').text('Authentication timed out, was aborted or not allowed');
+		else if (err.name == 'InvalidStateError')
+			$('#webauthn-alert').text('Device is not registered for your account');
+		else if (err.name == 'AbortError')
+			$('#webauthn-alert').text('Authentication was aborted');
+		else if (err.name == 'NotSupportedError')
+			$('#webauthn-alert').text('U2F and FIDO2 devices are not supported by your browser');
+		/* errors from fetch() */
+		else if (err.name == 'TypeError')
+			$('#webauthn-alert').text('Could not connect to server');
+		/* our own errors */
+		else if (err.name == 'Error')
+			$('#webauthn-alert').text(err.message);
+		/* fallback */
+		else
+			$('#webauthn-alert').text('Authentication failed ('+err+')');
+		$('#webauthn-alert').removeClass('d-none');
+		$('#webauthn-spinner').addClass('d-none');
+		$('#webauthn-btn-text').text('Try FIDO token again');
+		$('#webauthn-btn').prop('disabled', false);
+	});
+}
+
+$('#webauthn-btn').on('click', begin_webauthn);
+if (typeof(PublicKeyCredential) != "undefined") {
+	$('.webauthn-group').removeClass('d-none');
+	begin_webauthn();
+} else {
+	$('#webauthn-unsupported').removeClass('d-none');
+}
+</script>
+{% endif %}
+
+{% endblock %}
diff --git a/uffd/mfa/templates/disable.html b/uffd/mfa/templates/disable.html
new file mode 100644
index 0000000000000000000000000000000000000000..679ff1bad97463963d0d8be0e54512b5396a5f66
--- /dev/null
+++ b/uffd/mfa/templates/disable.html
@@ -0,0 +1,12 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<p>When you proceed, all recovery codes, registered authenticator applications and devices will be invalidated. 
+You can later generate new recovery codes and setup your applications and devices again.</p>
+
+<form class="form" action="{{ url_for('mfa.disable_confirm') }}" method="POST">
+	<button type="submit" class="btn btn-danger btn-block">Disable two-factor authentication</button>
+</form>
+
+{% endblock %}
diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/setup.html
new file mode 100644
index 0000000000000000000000000000000000000000..93b634b2fa02e5867666e8c3d61a8e12693def63
--- /dev/null
+++ b/uffd/mfa/templates/setup.html
@@ -0,0 +1,247 @@
+{% extends 'base.html' %}
+
+{# Two-factor auth can be in three states:
+
+mfa_init: The user has not setup any two-factor methods or recovery codes
+mfa_setup: The user has setup recovery codes but no two-factor methods. Two-factor authentication is still disabled.
+mfa_enabled: The user has setup at least one two-factor method. Two-factor authentication is enabled.
+
+#}
+
+{% set mfa_enabled = totp_methods or webauthn_methods %}
+{% set mfa_init = not recovery_methods and not mfa_enabled %}
+{% set mfa_setup = recovery_methods and not mfa_enabled %}
+
+{% block body %}
+<p>Two-factor authentication is currently <strong>{{ 'enabled' if mfa_enabled else 'disabled' }}</strong>.
+{% if mfa_init %}
+You need to generate recovery codes and setup at least one authentication method to enable two-factor authentication.
+{% elif mfa_setup %}
+You need to setup at least one authentication method to enable two-factor authentication.
+{% endif %}
+</p>
+{% if mfa_setup or mfa_enabled %}
+<div class="clearfix">
+	{% if mfa_enabled %}
+	<form class="form float-right" action="{{ url_for('mfa.disable') }}">
+		<button type="submit" class="btn btn-danger mb-2">Disable two-factor authentication</button>
+	</form>
+	{% else %}
+	<form class="form float-right" action="{{ url_for('mfa.disable_confirm') }}" method="POST">
+		<button type="submit" class="btn btn-light mb-2">Reset two-factor configuration</button>
+	</form>
+	{% endif %}
+</div>
+{% endif %}
+
+<hr>
+
+<div class="row mt-3">
+	<div class="col-12 col-md-5">
+		<h4>Recovery Codes</h4>
+		<p>Recovery codes allow you to login and setup new two-factor methods when you lost your registered second factor.</p>
+		<p>
+			{% if mfa_init %}<strong>{% endif %}
+			You need to setup recovery codes before you can setup up authenticator apps or U2F/FIDO2 devices.
+			{% if mfa_init %}</strong>{% endif %}
+			Each code can only be used once.
+		</p>
+	</div>
+
+	<div class="col-12 col-md-7">
+		<form class="form" action="{{ url_for('mfa.setup_recovery') }}" method="POST">
+			{% if mfa_init %}
+			<button type="submit" class="btn btn-primary mb-2 col">Generate recovery codes to enable two-factor authentication</button>
+			{% else %}
+			<button type="submit" class="btn btn-primary mb-2 col">Generate new recovery codes</button>
+			{% endif %}
+		</form>
+
+		{% if recovery_methods %}
+		<p>{{ recovery_methods|length }} recovery codes remain</p>
+		{% elif not recovery_methods and mfa_enabled %}
+		<p><strong>You have no remaining recovery codes.</strong></p>
+		{% endif %}
+	</div>
+</div>
+
+<hr>
+
+<div class="row mt-3">
+	<div class="col-12 col-md-5">
+		<h4>Authenticator Apps (TOTP)</h4>
+		<p>Use an authenticator application on your mobile device as a second factor.</p>
+		<p>The authenticator app generates a 6-digit one-time code each time you login.
+		Compatible apps are freely available for most phones.</p>
+	</div>
+
+	<div class="col-12 col-md-7">
+		<form class="form mb-2" action="{{ url_for('mfa.setup_totp') }}">
+			<div class="row m-0">
+				<label class="sr-only" for="totp-name">Name</label>
+				<input type="text" name="name" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="totp-name" placeholder="Name" required {{ 'disabled' if mfa_init }}>
+				<button type="submit" id="totp-submit" class="btn btn-primary mb-2 col" {{ 'disabled' if mfa_init }}>Setup new app</button>
+			</div>
+		</form>
+
+		<table class="table">
+			<thead>
+				<tr>
+					<th scope="col">Name</th>
+					<th scope="col">Registered On</th>
+					<th scope="col"></th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for method in totp_methods %}
+				<tr>
+					<td>{{ method.name }}</td>
+					<td>{{ method.created.strftime('%b %d, %Y') }}</td>
+					<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_totp', id=method.id) }}">Delete</a></td>
+				</tr>
+				{% endfor %}
+				{% if not totp_methods %}
+				<tr class="table-secondary">
+					<td colspan=3 class="text-center">No authenticator apps registered yet</td>
+				</tr>
+				{% endif %}
+			</tbody>
+		</table>
+	</div>
+</div>
+
+<hr>
+
+<div class="row">
+	<div class="col-12 col-md-5">
+		<h4>U2F and FIDO2 Devices</h4>
+		<p>Use an U2F or FIDO2 compatible hardware security key as a second factor.</p>
+		<p>U2F and FIDO2 devices are not supported by all browsers and can be particularly difficult to use on mobile devices.
+		<strong>It is strongly recommended to also setup an authenticator app</strong> to be able to login on all browsers.</p>
+	</div>
+
+	<div class="col-12 col-md-7">
+		{% if not webauthn_supported %}
+		<div class="alert alert-warning" role="alert">U2F/FIDO2 support not enabled</div>
+		{% endif %}
+		<noscript>
+			<div class="alert alert-warning" role="alert">Enable javascript in your browser to use U2F and FIDO2 devices!</div>
+		</noscript>
+		<div id="webauthn-alert" class="alert alert-warning d-none" role="alert"></div>
+		<form id="webauthn-form" class="form mb-2">
+			<div class="row m-0">
+				<label class="sr-only" for="webauthn-name">Name</label>
+				<input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="webauthn-name" placeholder="Name" required disabled>
+				<button type="submit" id="webauthn-btn" class="btn btn-primary mb-2 col" disabled>
+					<span id="webauthn-spinner" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
+					<span id="webauthn-btn-text">Setup new device</span>
+				</button>
+			</div>
+		</form>
+
+		<table class="table">
+			<thead>
+				<tr>
+					<th scope="col">Name</th>
+					<th scope="col">Registered On</th>
+					<th scope="col"></th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for method in webauthn_methods %}
+				<tr>
+					<td>{{ method.name }}</td>
+					<td>{{ method.created.strftime('%b %d, %Y') }}</td>
+					<td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_webauthn', id=method.id) }}">Delete</a></td>
+				</tr>
+				{% endfor %}
+				{% if not webauthn_methods %}
+				<tr class="table-secondary">
+					<td colspan=3 class="text-center">No U2F/FIDO2 devices registered yet</td>
+				</tr>
+				{% endif %}
+			</tbody>
+		</table>
+	</div>
+</div>
+
+{% if webauthn_supported %}
+<script src="{{ url_for('static', filename="cbor.js") }}"></script>
+<script>
+
+$('#webauthn-form').on('submit', function(e) {
+	$('#webauthn-alert').addClass('d-none');
+	$('#webauthn-spinner').removeClass('d-none');
+	$('#webauthn-btn-text').text('Contacting server');
+	$('#webauthn-btn').prop('disabled', true);
+	fetch({{ url_for('mfa.setup_webauthn_begin')|tojson }}, {
+		method: 'POST',
+	}).then(function(response) {
+		if (response.ok)
+			return response.arrayBuffer();
+		if (response.status == 403)
+			throw new Error('You need to generate recovery codes first');
+		throw new Error('Server error');
+	}).then(CBOR.decode).then(function(options) {
+		$('#webauthn-btn-text').text('Waiting for response from your device');
+		return navigator.credentials.create(options);
+	}).then(function(attestation) {
+		return fetch({{ url_for('mfa.setup_webauthn_complete')|tojson }}, {
+			method: 'POST',
+			headers: {'Content-Type': 'application/cbor'},
+			body: CBOR.encode({
+				"attestationObject": new Uint8Array(attestation.response.attestationObject),
+				"clientDataJSON": new Uint8Array(attestation.response.clientDataJSON),
+				"name": $('#webauthn-name').val()
+			})
+		});
+	}).then(function(response) {
+		if (response.ok) {
+			$('#webauthn-spinner').addClass('d-none');
+			$('#webauthn-btn-text').text('Success');
+			window.location = {{ url_for('mfa.setup')|tojson }};
+		} else {
+			throw new Error('Response from authenticator rejected');
+		}
+	}, function(err) {
+		console.log(err);
+		/* various webauthn errors */
+		if (err.name == 'NotAllowedError')
+			$('#webauthn-alert').text('Registration timed out, was aborted or not allowed');
+		else if (err.name == 'InvalidStateError')
+			$('#webauthn-alert').text('You attempted to register a device that is already registered');
+		else if (err.name == 'AbortError')
+			$('#webauthn-alert').text('Registration was aborted');
+		else if (err.name == 'NotSupportedError')
+			$('#webauthn-alert').text('U2F and FIDO2 devices are not supported by your browser');
+		/* errors from fetch() */
+		else if (err.name == 'TypeError')
+			$('#webauthn-alert').text('Could not connect to server');
+		/* our own errors */
+		else if (err.name == 'Error')
+			$('#webauthn-alert').text(err.message);
+		/* fallback */
+		else
+			$('#webauthn-alert').text('Registration failed ('+err+')');
+		$('#webauthn-alert').removeClass('d-none');
+		$('#webauthn-spinner').addClass('d-none');
+		$('#webauthn-btn-text').text('Retry registration');
+		$('#webauthn-btn').prop('disabled', false);
+	});
+	return false;
+});
+
+if (typeof(PublicKeyCredential) != "undefined") {
+	{% if not mfa_init %}
+	$('#webauthn-btn').prop('disabled', false);
+	$('#webauthn-name').prop('disabled', false);
+	{% endif %}
+} else {
+	$('#webauthn-alert').text('U2F and FIDO2 devices are not supported by your browser');
+	$('#webauthn-alert').removeClass('d-none');
+}
+
+</script>
+{% endif %}
+
+{% endblock %}
diff --git a/uffd/mfa/templates/setup_recovery.html b/uffd/mfa/templates/setup_recovery.html
new file mode 100644
index 0000000000000000000000000000000000000000..b5e00467702e9622ec12cede71abdb3d7183fcc6
--- /dev/null
+++ b/uffd/mfa/templates/setup_recovery.html
@@ -0,0 +1,25 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<h1 class="d-none d-print-block">Recovery Codes</h1>
+
+<p>Recovery codes allow you to login when you lose access to your authenticator app or U2F/FIDO device. Each code can only be used once.</p>
+
+<div class="text-monospace">
+	<ul>
+	{% for method in methods %}
+		<li>{{ method.code }}</li>
+	{% endfor %}
+	</ul>
+</div>
+
+<p>These are your new recovery codes. Make sure to store them in a safe place or you risk losing access to your account. All previous recovery codes are now invalid.</p>
+
+<div class="btn-toolbar">
+	<a class="ml-auto mb-2 btn btn-primary d-print-none" href="{{ url_for('mfa.setup') }}">Continue</a>
+	<a class="ml-2 mb-2 btn btn-light d-print-none" href="{{ methods|map(attribute='code')|join('\n')|datauri }}" download="uffd-recovery-codes">Download codes</a>
+	<button class="ml-2 mb-2 btn btn-light d-print-none" type="button" onClick="window.print()">Print codes</button>
+</div>
+
+{% endblock %}
diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/setup_totp.html
new file mode 100644
index 0000000000000000000000000000000000000000..f0850914373e8e1dfe458a97c2fba9663e8d66e1
--- /dev/null
+++ b/uffd/mfa/templates/setup_totp.html
@@ -0,0 +1,35 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<p>Install an authenticator application on your mobile device like FreeOTP or Google Authenticator and scan this QR code. On Apple devices you can use an app called "Authenticator".</p>
+
+<div class="row">
+	<div class="mx-auto col-9 col-md-4 mb-3">
+		<a href="{{ method.key_uri }}">
+			{{ method.key_uri|qrcode_svg(width='100%', height='100%') }}
+		</a>
+	</div>
+	<div class="col-12 col-md-8">
+		<p>If you are on your mobile device and cannot scan the code, you can click on it to open it with your authenticator app. If that does not work, enter the following details manually into your authenticator app:</p>
+		<p>
+		Issuer: {{ method.issuer }}<br>
+		Account: {{ method.accountname }}<br>
+		Secret: {{ method.key }}<br>
+		Type: TOTP (time-based)<br>
+		Digits: 6<br>
+		Hash algorithm: SHA1<br>
+		Interval/period: 30 seconds
+		</p>
+
+	</div>
+</div>
+
+<form action="{{ url_for('mfa.setup_totp_finish', name=name) }}" method="POST" class="form">
+	<div class="row m-0">
+		<input type="text" name="code" class="form-control mb-2 mr-2 col-auto col-md" id="code" placeholder="Code" required autofocus>
+		<button type="submit" class="btn btn-primary mb-2 col col-md-auto">Verify and complete setup</button>
+	</div>
+</form>
+
+{% endblock %}
diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..7255ae026ec9c3992d9fdc88b917b76cb5edd8d4
--- /dev/null
+++ b/uffd/mfa/views.py
@@ -0,0 +1,245 @@
+from warnings import warn
+import urllib.parse
+
+from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort
+
+from uffd.database import db
+from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod
+from uffd.session.views import get_current_user, login_required, pre_mfa_login_required
+from uffd.ldap import uid_to_dn
+from uffd.user.models import User
+from uffd.csrf import csrf_protect
+
+bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/')
+
+@bp.route('/', methods=['GET'])
+@login_required()
+def setup():
+	user = get_current_user()
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+	return render_template('setup.html', totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
+
+@bp.route('/setup/disable', methods=['GET'])
+@login_required()
+def disable():
+	return render_template('disable.html')
+
+@bp.route('/setup/disable', methods=['POST'])
+@login_required()
+@csrf_protect(blueprint=bp)
+def disable_confirm():
+	user = get_current_user()
+	MFAMethod.query.filter_by(dn=user.dn).delete()
+	db.session.commit()
+	return redirect(url_for('mfa.setup'))
+
+@bp.route('/admin/<int:uid>/disable')
+@login_required()
+@csrf_protect(blueprint=bp)
+def admin_disable(uid):
+	# Group cannot be checked with login_required kwarg, because the config
+	# variable is not available when the decorator is processed
+	if not get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP']):
+		flash('Access denied')
+		return redirect(url_for('index'))
+	user = User.from_ldap_dn(uid_to_dn(uid))
+	MFAMethod.query.filter_by(dn=user.dn).delete()
+	db.session.commit()
+	flash('Two-factor authentication was reset')
+	return redirect(url_for('user.show', uid=uid))
+
+@bp.route('/setup/recovery', methods=['POST'])
+@login_required()
+@csrf_protect(blueprint=bp)
+def setup_recovery():
+	user = get_current_user()
+	for method in RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+		db.session.delete(method)
+	methods = []
+	for _ in range(10):
+		method = RecoveryCodeMethod(user)
+		methods.append(method)
+		db.session.add(method)
+	db.session.commit()
+	return render_template('setup_recovery.html', methods=methods)
+
+@bp.route('/setup/totp', methods=['GET'])
+@login_required()
+def setup_totp():
+	user = get_current_user()
+	method = TOTPMethod(user)
+	session['mfa_totp_key'] = method.key
+	return render_template('setup_totp.html', method=method, name=request.values['name'])
+
+@bp.route('/setup/totp', methods=['POST'])
+@login_required()
+@csrf_protect(blueprint=bp)
+def setup_totp_finish():
+	user = get_current_user()
+	if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+		flash('Generate recovery codes first!')
+		return redirect(url_for('mfa.setup'))
+	method = TOTPMethod(user, name=request.values['name'], key=session.pop('mfa_totp_key'))
+	if method.verify(request.form['code']):
+		db.session.add(method)
+		db.session.commit()
+		return redirect(url_for('mfa.setup'))
+	flash('Code is invalid')
+	return redirect(url_for('mfa.setup_totp', name=request.values['name']))
+
+@bp.route('/setup/totp/<int:id>/delete')
+@login_required()
+@csrf_protect(blueprint=bp)
+def delete_totp(id): #pylint: disable=redefined-builtin
+	user = get_current_user()
+	method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
+	db.session.delete(method)
+	db.session.commit()
+	return redirect(url_for('mfa.setup'))
+
+# WebAuthn support is optional because fido2 has a pretty unstable
+# interface (v0.5.0 on buster and current version are completely
+# incompatible) and might be difficult to install with the correct version
+try:
+	from fido2.client import ClientData
+	from fido2.server import Fido2Server, RelyingParty
+	from fido2.ctap2 import AttestationObject, AuthenticatorData
+	from fido2 import cbor
+	WEBAUTHN_SUPPORTED = True
+except ImportError as err:
+	warn('2FA WebAuthn support disabled because import of the fido2 module failed (%s)'%err)
+	WEBAUTHN_SUPPORTED = False
+
+bp.add_app_template_global(WEBAUTHN_SUPPORTED, name='webauthn_supported')
+
+if WEBAUTHN_SUPPORTED:
+	def get_webauthn_server():
+		return Fido2Server(RelyingParty(current_app.config.get('MFA_RP_ID', urllib.parse.urlsplit(request.url).hostname), current_app.config['MFA_RP_NAME']))
+
+	@bp.route('/setup/webauthn/begin', methods=['POST'])
+	@login_required()
+	@csrf_protect(blueprint=bp)
+	def setup_webauthn_begin():
+		user = get_current_user()
+		if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+			abort(403)
+		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		creds = [method.cred for method in methods]
+		server = get_webauthn_server()
+		registration_data, state = server.register_begin(
+			{
+				"id": user.dn.encode(),
+				"name": user.loginname,
+				"displayName": user.displayname,
+			},
+			creds,
+			user_verification='discouraged',
+		)
+		session["webauthn-state"] = state
+		return cbor.dumps(registration_data)
+
+	@bp.route('/setup/webauthn/complete', methods=['POST'])
+	@login_required()
+	@csrf_protect(blueprint=bp)
+	def setup_webauthn_complete():
+		user = get_current_user()
+		server = get_webauthn_server()
+		data = cbor.loads(request.get_data())[0]
+		client_data = ClientData(data["clientDataJSON"])
+		att_obj = AttestationObject(data["attestationObject"])
+		auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj)
+		method = WebauthnMethod(user, auth_data.credential_data, name=data['name'])
+		db.session.add(method)
+		db.session.commit()
+		return cbor.dumps({"status": "OK"})
+
+	@bp.route("/auth/webauthn/begin", methods=["POST"])
+	@pre_mfa_login_required(no_redirect=True)
+	def auth_webauthn_begin():
+		user = get_current_user()
+		server = get_webauthn_server()
+		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		creds = [method.cred for method in methods]
+		if not creds:
+			abort(404)
+		auth_data, state = server.authenticate_begin(creds, user_verification='discouraged')
+		session["webauthn-state"] = state
+		return cbor.dumps(auth_data)
+
+	@bp.route("/auth/webauthn/complete", methods=["POST"])
+	@pre_mfa_login_required(no_redirect=True)
+	def auth_webauthn_complete():
+		user = get_current_user()
+		server = get_webauthn_server()
+		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		creds = [method.cred for method in methods]
+		if not creds:
+			abort(404)
+		data = cbor.loads(request.get_data())[0]
+		credential_id = data["credentialId"]
+		client_data = ClientData(data["clientDataJSON"])
+		auth_data = AuthenticatorData(data["authenticatorData"])
+		signature = data["signature"]
+		# authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster)
+		# does not check signCount, although the spec recommends it
+		server.authenticate_complete(
+			session.pop("webauthn-state"),
+			creds,
+			credential_id,
+			client_data,
+			auth_data,
+			signature,
+		)
+		session['user_mfa'] = True
+		return cbor.dumps({"status": "OK"})
+
+@bp.route('/setup/webauthn/<int:id>/delete')
+@login_required()
+@csrf_protect(blueprint=bp)
+def delete_webauthn(id): #pylint: disable=redefined-builtin
+	user = get_current_user()
+	method = WebauthnMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
+	db.session.delete(method)
+	db.session.commit()
+	return redirect(url_for('mfa.setup'))
+
+@bp.route('/auth', methods=['GET'])
+@pre_mfa_login_required()
+def auth():
+	user = get_current_user()
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+	if not totp_methods and not webauthn_methods:
+		session['user_mfa'] = True
+	if session.get('user_mfa'):
+		return redirect(request.values.get('ref', url_for('index')))
+	return render_template('auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
+			webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
+
+@bp.route('/auth', methods=['POST'])
+@pre_mfa_login_required()
+def auth_finish():
+	user = get_current_user()
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	for method in totp_methods:
+		if method.verify(request.form['code']):
+			session['user_mfa'] = True
+			return redirect(request.values.get('ref', url_for('index')))
+	for method in recovery_methods:
+		if method.verify(request.form['code']):
+			db.session.delete(method)
+			db.session.commit()
+			session['user_mfa'] = True
+			if len(recovery_methods) <= 1:
+				flash('You have exhausted your recovery codes. Please generate new ones now!')
+				return redirect(url_for('mfa.setup'))
+			if len(recovery_methods) <= 5:
+				flash('You only have a few recovery codes remaining. Make sure to generate new ones before they run out.')
+				return redirect(url_for('mfa.setup'))
+			return redirect(request.values.get('ref', url_for('index')))
+	flash('Two-factor authentication failed')
+	return redirect(url_for('mfa.auth', ref=request.values.get('ref')))
diff --git a/uffd/selfservice/templates/self.html b/uffd/selfservice/templates/self.html
index 874fac7d0c6f0883b34b59285df68cb049ebc871..026aaf61fec084f5dcf1e09c99069a60f63bbcc9 100644
--- a/uffd/selfservice/templates/self.html
+++ b/uffd/selfservice/templates/self.html
@@ -1,6 +1,11 @@
 {% extends 'base.html' %}
 
 {% block body %}
+
+<div class="btn-toolbar">
+	<a class="ml-auto mb-3 btn btn-primary" href="{{ url_for('mfa.setup') }}">Manage two-factor authentication</a>
+</div>
+
 <form action="{{ url_for("selfservice.update") }}" method="POST" onInput="
 	password2.setCustomValidity(password1.value != password2.value ? 'Passwords do not match.' : '');
 	password1.setCustomValidity((password1.value.length < 8 || password1.value.length == 0) ? 'Password is too short' : '') ">
diff --git a/uffd/session/templates/login.html b/uffd/session/templates/login.html
index 9dfd5ae6389dc5fa901dde27caeba2ba1af3d07e..0332394891e194433e77b0b57500461164511c82 100644
--- a/uffd/session/templates/login.html
+++ b/uffd/session/templates/login.html
@@ -12,7 +12,7 @@
 		</div>
 		<div class="form-group col-12">
 			<label for="user-loginname">Login Name</label>
-			<input type="text" class="form-control" id="user-loginname" name="loginname" required="required" tabindex = "1">
+			<input type="text" class="form-control" id="user-loginname" name="loginname" required="required" tabindex = "1" autofocus>
 		</div>
 		<div class="form-group col-12">
 			<label for="user-password1">Password</label>
diff --git a/uffd/session/views.py b/uffd/session/views.py
index 5cb275bbc412e93192e2e3477f4c2cdda5e591d0..310ceaf67fb3dabf0d3c53228bd4b9b54b44965c 100644
--- a/uffd/session/views.py
+++ b/uffd/session/views.py
@@ -2,7 +2,7 @@ import datetime
 import secrets
 import functools
 
-from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session, abort
 
 from uffd.user.models import User
 from uffd.ldap import user_conn, uid_to_dn
@@ -22,11 +22,9 @@ def login():
 	username = request.form['loginname']
 	password = request.form['password']
 	conn = user_conn(username, password)
-	if not conn:
-		flash('Login name or password is wrong')
-		return redirect(url_for('.login'))
-	conn.search(conn.user, '(objectClass=person)')
-	if not len(conn.entries) == 1:
+	if conn:
+		conn.search(conn.user, '(objectClass=person)')
+	if not conn or len(conn.entries) != 1:
 		flash('Login name or password is wrong')
 		return render_template('login.html', ref=request.values.get('ref'))
 	user = User.from_ldap(conn.entries[0])
@@ -36,7 +34,7 @@ def login():
 	session['user_uid'] = user.uid
 	session['logintime'] = datetime.datetime.now().timestamp()
 	session['_csrf_token'] = secrets.token_hex(128)
-	return redirect(request.values.get('ref', url_for('index')))
+	return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
 
 def get_current_user():
 	if not session.get('user_uid'):
@@ -45,22 +43,45 @@ def get_current_user():
 		request.current_user = User.from_ldap_dn(uid_to_dn(session['user_uid']))
 	return request.current_user
 
-def is_valid_session():
+def login_valid():
 	user = get_current_user()
 	if not user:
 		return False
 	if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
 		return False
 	return True
+
+def is_valid_session():
+	if not login_valid():
+		return False
+	if not session.get('user_mfa'):
+		return False
+	return True
 bp.add_app_template_global(is_valid_session)
 
+def pre_mfa_login_required(no_redirect=False):
+	def wrapper(func):
+		@functools.wraps(func)
+		def decorator(*args, **kwargs):
+			if not login_valid() or datetime.datetime.now().timestamp() > session['logintime'] + 10*60:
+				session.clear()
+				if no_redirect:
+					abort(403)
+				flash('You need to login first')
+				return redirect(url_for('session.login', ref=request.url))
+			return func(*args, **kwargs)
+		return decorator
+	return wrapper
+
 def login_required(group=None):
 	def wrapper(func):
 		@functools.wraps(func)
 		def decorator(*args, **kwargs):
-			if not is_valid_session():
+			if not login_valid():
 				flash('You need to login first')
 				return redirect(url_for('session.login', ref=request.url))
+			if not session.get('user_mfa'):
+				return redirect(url_for('mfa.auth', ref=request.url))
 			if not get_current_user().is_in_group(group):
 				flash('Access denied')
 				return redirect(url_for('index'))
diff --git a/uffd/static/cbor.js b/uffd/static/cbor.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e1f300df35185ec0e7ab0ea65ed998d32970809
--- /dev/null
+++ b/uffd/static/cbor.js
@@ -0,0 +1,406 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Patrick Gansterer <paroga@paroga.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+(function(global, undefined) { "use strict";
+var POW_2_24 = 5.960464477539063e-8,
+    POW_2_32 = 4294967296,
+    POW_2_53 = 9007199254740992;
+
+function encode(value) {
+  var data = new ArrayBuffer(256);
+  var dataView = new DataView(data);
+  var lastLength;
+  var offset = 0;
+
+  function prepareWrite(length) {
+    var newByteLength = data.byteLength;
+    var requiredLength = offset + length;
+    while (newByteLength < requiredLength)
+      newByteLength <<= 1;
+    if (newByteLength !== data.byteLength) {
+      var oldDataView = dataView;
+      data = new ArrayBuffer(newByteLength);
+      dataView = new DataView(data);
+      var uint32count = (offset + 3) >> 2;
+      for (var i = 0; i < uint32count; ++i)
+        dataView.setUint32(i << 2, oldDataView.getUint32(i << 2));
+    }
+
+    lastLength = length;
+    return dataView;
+  }
+  function commitWrite() {
+    offset += lastLength;
+  }
+  function writeFloat64(value) {
+    commitWrite(prepareWrite(8).setFloat64(offset, value));
+  }
+  function writeUint8(value) {
+    commitWrite(prepareWrite(1).setUint8(offset, value));
+  }
+  function writeUint8Array(value) {
+    var dataView = prepareWrite(value.length);
+    for (var i = 0; i < value.length; ++i)
+      dataView.setUint8(offset + i, value[i]);
+    commitWrite();
+  }
+  function writeUint16(value) {
+    commitWrite(prepareWrite(2).setUint16(offset, value));
+  }
+  function writeUint32(value) {
+    commitWrite(prepareWrite(4).setUint32(offset, value));
+  }
+  function writeUint64(value) {
+    var low = value % POW_2_32;
+    var high = (value - low) / POW_2_32;
+    var dataView = prepareWrite(8);
+    dataView.setUint32(offset, high);
+    dataView.setUint32(offset + 4, low);
+    commitWrite();
+  }
+  function writeTypeAndLength(type, length) {
+    if (length < 24) {
+      writeUint8(type << 5 | length);
+    } else if (length < 0x100) {
+      writeUint8(type << 5 | 24);
+      writeUint8(length);
+    } else if (length < 0x10000) {
+      writeUint8(type << 5 | 25);
+      writeUint16(length);
+    } else if (length < 0x100000000) {
+      writeUint8(type << 5 | 26);
+      writeUint32(length);
+    } else {
+      writeUint8(type << 5 | 27);
+      writeUint64(length);
+    }
+  }
+
+  function encodeItem(value) {
+    var i;
+
+    if (value === false)
+      return writeUint8(0xf4);
+    if (value === true)
+      return writeUint8(0xf5);
+    if (value === null)
+      return writeUint8(0xf6);
+    if (value === undefined)
+      return writeUint8(0xf7);
+
+    switch (typeof value) {
+      case "number":
+        if (Math.floor(value) === value) {
+          if (0 <= value && value <= POW_2_53)
+            return writeTypeAndLength(0, value);
+          if (-POW_2_53 <= value && value < 0)
+            return writeTypeAndLength(1, -(value + 1));
+        }
+        writeUint8(0xfb);
+        return writeFloat64(value);
+
+      case "string":
+        var utf8data = [];
+        for (i = 0; i < value.length; ++i) {
+          var charCode = value.charCodeAt(i);
+          if (charCode < 0x80) {
+            utf8data.push(charCode);
+          } else if (charCode < 0x800) {
+            utf8data.push(0xc0 | charCode >> 6);
+            utf8data.push(0x80 | charCode & 0x3f);
+          } else if (charCode < 0xd800) {
+            utf8data.push(0xe0 | charCode >> 12);
+            utf8data.push(0x80 | (charCode >> 6)  & 0x3f);
+            utf8data.push(0x80 | charCode & 0x3f);
+          } else {
+            charCode = (charCode & 0x3ff) << 10;
+            charCode |= value.charCodeAt(++i) & 0x3ff;
+            charCode += 0x10000;
+
+            utf8data.push(0xf0 | charCode >> 18);
+            utf8data.push(0x80 | (charCode >> 12)  & 0x3f);
+            utf8data.push(0x80 | (charCode >> 6)  & 0x3f);
+            utf8data.push(0x80 | charCode & 0x3f);
+          }
+        }
+
+        writeTypeAndLength(3, utf8data.length);
+        return writeUint8Array(utf8data);
+
+      default:
+        var length;
+        if (Array.isArray(value)) {
+          length = value.length;
+          writeTypeAndLength(4, length);
+          for (i = 0; i < length; ++i)
+            encodeItem(value[i]);
+        } else if (value instanceof Uint8Array) {
+          writeTypeAndLength(2, value.length);
+          writeUint8Array(value);
+        } else {
+          var keys = Object.keys(value);
+          length = keys.length;
+          writeTypeAndLength(5, length);
+          for (i = 0; i < length; ++i) {
+            var key = keys[i];
+            encodeItem(key);
+            encodeItem(value[key]);
+          }
+        }
+    }
+  }
+
+  encodeItem(value);
+
+  if ("slice" in data)
+    return data.slice(0, offset);
+
+  var ret = new ArrayBuffer(offset);
+  var retView = new DataView(ret);
+  for (var i = 0; i < offset; ++i)
+    retView.setUint8(i, dataView.getUint8(i));
+  return ret;
+}
+
+function decode(data, tagger, simpleValue) {
+  var dataView = new DataView(data);
+  var offset = 0;
+
+  if (typeof tagger !== "function")
+    tagger = function(value) { return value; };
+  if (typeof simpleValue !== "function")
+    simpleValue = function() { return undefined; };
+
+  function commitRead(length, value) {
+    offset += length;
+    return value;
+  }
+  function readArrayBuffer(length) {
+    return commitRead(length, new Uint8Array(data, offset, length));
+  }
+  function readFloat16() {
+    var tempArrayBuffer = new ArrayBuffer(4);
+    var tempDataView = new DataView(tempArrayBuffer);
+    var value = readUint16();
+
+    var sign = value & 0x8000;
+    var exponent = value & 0x7c00;
+    var fraction = value & 0x03ff;
+
+    if (exponent === 0x7c00)
+      exponent = 0xff << 10;
+    else if (exponent !== 0)
+      exponent += (127 - 15) << 10;
+    else if (fraction !== 0)
+      return (sign ? -1 : 1) * fraction * POW_2_24;
+
+    tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13);
+    return tempDataView.getFloat32(0);
+  }
+  function readFloat32() {
+    return commitRead(4, dataView.getFloat32(offset));
+  }
+  function readFloat64() {
+    return commitRead(8, dataView.getFloat64(offset));
+  }
+  function readUint8() {
+    return commitRead(1, dataView.getUint8(offset));
+  }
+  function readUint16() {
+    return commitRead(2, dataView.getUint16(offset));
+  }
+  function readUint32() {
+    return commitRead(4, dataView.getUint32(offset));
+  }
+  function readUint64() {
+    return readUint32() * POW_2_32 + readUint32();
+  }
+  function readBreak() {
+    if (dataView.getUint8(offset) !== 0xff)
+      return false;
+    offset += 1;
+    return true;
+  }
+  function readLength(additionalInformation) {
+    if (additionalInformation < 24)
+      return additionalInformation;
+    if (additionalInformation === 24)
+      return readUint8();
+    if (additionalInformation === 25)
+      return readUint16();
+    if (additionalInformation === 26)
+      return readUint32();
+    if (additionalInformation === 27)
+      return readUint64();
+    if (additionalInformation === 31)
+      return -1;
+    throw "Invalid length encoding";
+  }
+  function readIndefiniteStringLength(majorType) {
+    var initialByte = readUint8();
+    if (initialByte === 0xff)
+      return -1;
+    var length = readLength(initialByte & 0x1f);
+    if (length < 0 || (initialByte >> 5) !== majorType)
+      throw "Invalid indefinite length element";
+    return length;
+  }
+
+  function appendUtf16Data(utf16data, length) {
+    for (var i = 0; i < length; ++i) {
+      var value = readUint8();
+      if (value & 0x80) {
+        if (value < 0xe0) {
+          value = (value & 0x1f) <<  6
+                | (readUint8() & 0x3f);
+          length -= 1;
+        } else if (value < 0xf0) {
+          value = (value & 0x0f) << 12
+                | (readUint8() & 0x3f) << 6
+                | (readUint8() & 0x3f);
+          length -= 2;
+        } else {
+          value = (value & 0x0f) << 18
+                | (readUint8() & 0x3f) << 12
+                | (readUint8() & 0x3f) << 6
+                | (readUint8() & 0x3f);
+          length -= 3;
+        }
+      }
+
+      if (value < 0x10000) {
+        utf16data.push(value);
+      } else {
+        value -= 0x10000;
+        utf16data.push(0xd800 | (value >> 10));
+        utf16data.push(0xdc00 | (value & 0x3ff));
+      }
+    }
+  }
+
+  function decodeItem() {
+    var initialByte = readUint8();
+    var majorType = initialByte >> 5;
+    var additionalInformation = initialByte & 0x1f;
+    var i;
+    var length;
+
+    if (majorType === 7) {
+      switch (additionalInformation) {
+        case 25:
+          return readFloat16();
+        case 26:
+          return readFloat32();
+        case 27:
+          return readFloat64();
+      }
+    }
+
+    length = readLength(additionalInformation);
+    if (length < 0 && (majorType < 2 || 6 < majorType))
+      throw "Invalid length";
+
+    switch (majorType) {
+      case 0:
+        return length;
+      case 1:
+        return -1 - length;
+      case 2:
+        if (length < 0) {
+          var elements = [];
+          var fullArrayLength = 0;
+          while ((length = readIndefiniteStringLength(majorType)) >= 0) {
+            fullArrayLength += length;
+            elements.push(readArrayBuffer(length));
+          }
+          var fullArray = new Uint8Array(fullArrayLength);
+          var fullArrayOffset = 0;
+          for (i = 0; i < elements.length; ++i) {
+            fullArray.set(elements[i], fullArrayOffset);
+            fullArrayOffset += elements[i].length;
+          }
+          return fullArray;
+        }
+        return readArrayBuffer(length);
+      case 3:
+        var utf16data = [];
+        if (length < 0) {
+          while ((length = readIndefiniteStringLength(majorType)) >= 0)
+            appendUtf16Data(utf16data, length);
+        } else
+          appendUtf16Data(utf16data, length);
+        return String.fromCharCode.apply(null, utf16data);
+      case 4:
+        var retArray;
+        if (length < 0) {
+          retArray = [];
+          while (!readBreak())
+            retArray.push(decodeItem());
+        } else {
+          retArray = new Array(length);
+          for (i = 0; i < length; ++i)
+            retArray[i] = decodeItem();
+        }
+        return retArray;
+      case 5:
+        var retObject = {};
+        for (i = 0; i < length || length < 0 && !readBreak(); ++i) {
+          var key = decodeItem();
+          retObject[key] = decodeItem();
+        }
+        return retObject;
+      case 6:
+        return tagger(decodeItem(), length);
+      case 7:
+        switch (length) {
+          case 20:
+            return false;
+          case 21:
+            return true;
+          case 22:
+            return null;
+          case 23:
+            return undefined;
+          default:
+            return simpleValue(length);
+        }
+    }
+  }
+
+  var ret = decodeItem();
+  if (offset !== data.byteLength)
+    throw "Remaining bytes";
+  return ret;
+}
+
+var obj = { encode: encode, decode: decode };
+
+if (typeof define === "function" && define.amd)
+  define("cbor/cbor", obj);
+else if (typeof module !== "undefined" && module.exports)
+  module.exports = obj;
+else if (!global.CBOR)
+  global.CBOR = obj;
+
+})(this);
diff --git a/uffd/template_helper.py b/uffd/template_helper.py
index 66e1e3347bef06a68854d34207b8c4c487904635..998bafaa4ecf3a7e745e226c7ddb73c7de25226f 100644
--- a/uffd/template_helper.py
+++ b/uffd/template_helper.py
@@ -1,12 +1,33 @@
 import random
 import subprocess
+import base64
 from datetime import timedelta, datetime
+import io
+
+from flask import Markup
+
+import qrcode
+import qrcode.image.svg
 
 def register_template_helper(app):
 	# debian ships jinja2 without this test...
 	def equalto(a, b):
 		return a == b
 
+	@app.template_filter()
+	def qrcode_svg(content, **attrs): #pylint: disable=unused-variable
+		img = qrcode.make(content, image_factory=qrcode.image.svg.SvgPathImage, border=0)
+		svg = img.get_image()
+		for key, value, in attrs.items():
+			svg.set(key, value)
+		buf = io.BytesIO()
+		img.save(buf)
+		return Markup(buf.getvalue().decode())
+
+	@app.template_filter()
+	def datauri(data, mimetype='text/plain'): #pylint: disable=unused-variable
+		return Markup('data:%s;base64,%s'%(mimetype, base64.b64encode(data.encode()).decode()))
+
 	@app.url_defaults
 	def static_version_inject(endpoint, values): #pylint: disable=unused-variable
 		if endpoint == 'static':
diff --git a/uffd/templates/base.html b/uffd/templates/base.html
index 22360863a86963433f02f9b1ea527c7cdd64c9c7..ac9e2af212b131655c802648889acd8159c44b1d 100644
--- a/uffd/templates/base.html
+++ b/uffd/templates/base.html
@@ -93,6 +93,8 @@
 		<main role="main" class="container">
 			{% block body %}
 			{% endblock body %}
+			<!-- spacer for floating footer -->
+			<div class="mb-5"></div>
 		</main>
 
 		<footer class="footer">
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user.html
index 68dcb7eaac2201bf74d5078991c97f2de8e8eb1b..bb9c1e6a95ca4ab2270b58575f4cb9c7b4793e98 100644
--- a/uffd/user/templates/user.html
+++ b/uffd/user/templates/user.html
@@ -6,6 +6,7 @@
 	<div class="float-sm-right pb-2">
 		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
 		<a href="{{ url_for("user.index") }}" class="btn btn-secondary">Cancel</a>
+		<a href="{{ url_for("mfa.admin_disable", uid=user.uid) }}" class="btn btn-secondary">Reset 2FA</a>
 		{% if user.uid %}
 			<a href="{{ url_for("user.delete", uid=user.uid) }}" class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a>
 		{% else %}