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/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..d30338b513da2f524d3af099d50fae618ea9de2b
--- /dev/null
+++ b/uffd/mfa/models.py
@@ -0,0 +1,93 @@
+import enum
+import datetime
+import secrets, time, struct, hmac, hashlib, base64, urllib.parse
+
+from flask import request, current_app
+from sqlalchemy import Column, Integer, Enum, Boolean, String, DateTime
+
+from uffd.database import db
+from uffd.user.models import User
+
+class MFAType(enum.Enum):
+	TOTP = 1
+
+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, u):
+		self.dn = u.dn
+
+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):
+		s = self.key + '='*(8 - (len(self.key) % 8))
+		return base64.b32decode(s.encode())
+
+	@property
+	def key_uri(self):
+		issuer = urllib.parse.quote(urllib.parse.urlsplit(request.url).netloc)
+		accountname = urllib.parse.quote(self.user.loginname.encode())
+		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
+
diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/auth.html
new file mode 100644
index 0000000000000000000000000000000000000000..176bff26243c272d60e065ed3f1690a80d158638
--- /dev/null
+++ b/uffd/mfa/templates/auth.html
@@ -0,0 +1,23 @@
+{% 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">
+			<h2 class="text-center">Two-Factor Authentication</h2>
+		</div>
+		<div class="form-group col-12">
+			<label for="mfa-code">Two-factor authentication code</label>
+			<input type="text" class="form-control" id="mfa-code" name="code" required="required" tabindex="1">
+		</div>
+		<div class="form-group col-12">
+			<button type="submit" class="btn btn-primary btn-block" tabindex="2">Verify</button>
+		</div>
+	</div>
+</div>
+</form>
+{% endblock %}
diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/setup.html
new file mode 100644
index 0000000000000000000000000000000000000000..f7f47241827d83b270cefb205a1c691d648758e9
--- /dev/null
+++ b/uffd/mfa/templates/setup.html
@@ -0,0 +1,35 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<div class="btn-toolbar">
+	<a class="btn btn-primary mb-2 ml-auto" href="{{ url_for('mfa.setup_totp') }}">Setup TOTP</a>
+</div>
+
+{% if methods %}
+<table class="table">
+	<thead>
+		<tr>
+			<th scope="col" colspan=2>Name</th>
+			<th scope="col">Registered On</th>
+			<th scope="col"></th>
+		</tr>
+	</thead>
+	<tbody>
+		{% for method in methods %}
+		<tr>
+			<td style="width: 0.5em;"><i class="fas fa-mobile-alt"></i></td>
+			<td>{{ method.name }}</td>
+			<td>{{ method.created }}</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 %}
+	</tbody>
+</table>
+{% else %}
+<div class="alert alert-info" role="alert">
+	You have not setup any two-factor methods yet!
+</div>
+{% endif %}
+
+{% endblock %}
diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/setup_totp.html
new file mode 100644
index 0000000000000000000000000000000000000000..8272a8b5ce0b295c4c5033d4dfb4b150b29ae3dc
--- /dev/null
+++ b/uffd/mfa/templates/setup_totp.html
@@ -0,0 +1,23 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<div style="max-width: 75vh;" class="mx-auto">
+	{{ method.key_uri|qrcode_svg(width='100%', height='100%') }}
+</div>
+
+<form action="{{ url_for('mfa.setup_totp') }}" method="POST" class="form">
+<div class="form-group">
+	<label for="code">Code</label>
+	<input name="code" type="text" class="form-control" required="required">
+</div>
+<div class="form-group">
+	<label for="name">Authenticator Name</label>
+	<input name="name" type="text" class="form-control" required="required">
+</div>
+<div class="form-group">
+	<button type="submit" class="btn btn-primary btn-block">Verify</button>
+</div>
+</form>
+
+{% endblock %}
diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..be8febd3423686c49393396a1183a8c7ef808d9a
--- /dev/null
+++ b/uffd/mfa/views.py
@@ -0,0 +1,64 @@
+from flask import Blueprint, render_template, session, request, redirect, url_for, flash
+
+from uffd.database import db
+from uffd.mfa.models import TOTPMethod
+from uffd.session.views import get_current_user, login_required
+
+bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/')
+
+
+@bp.route('/', methods=['GET'])
+@login_required()
+def setup():
+	user = get_current_user()
+	methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	return render_template('setup.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)
+
+@bp.route('/setup/totp', methods=['POST'])
+@login_required()
+def setup_totp_finish():
+	user = get_current_user()
+	method = TOTPMethod(user, name=request.form['name'], key=session['mfa_totp_key'])
+	del session['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'))
+
+@bp.route('/setup/totp/<int:id>/delete')
+@login_required()
+def delete_totp(id):
+	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'))
+
+@bp.route('/auth', methods=['GET'])
+@login_required()
+def auth():
+	user = get_current_user()
+	methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	return render_template('auth.html', ref=request.values.get('ref'), methods=methods)
+
+@bp.route('/auth', methods=['POST'])
+@login_required()
+def auth_finish():
+	user = get_current_user()
+	methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	for method in methods:
+		if method.verify(request.form['code']):
+			session['mfa_verifed'] = True
+			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')))