diff --git a/package.json b/package.json
index 49222490e65b7db14dc428bea83c8aa211799450..ed6a7b8d6287630f79a0889f8b4e5e973ea2442e 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
     "nan": "git+https://github.com/nodejs/nan.git",
     "npm": "^6.4.1",
     "npm-install-peers": "^1.2.1",
+    "qrcode": "^1.3.3",
     "schema-utils": "^0.3.0"
   }
 }
diff --git a/requirements.txt b/requirements.txt
index cb9a502b9649f31a1e9003a4558699e706c74c8d..9ab359a026836a111054d9d2337dabff6315cbe9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,7 @@ psycopg2==2.7.3  # PostgreSQL engine
 pytz==2017.2  # Timezone package
 djangorestframework==3.8.2
 requests==2.18.3
+pyotp==2.2.7
 
 
 # Django packages
diff --git a/scipost/admin.py b/scipost/admin.py
index 16bfca3b6a70b7314b1c170435bd7135d6799cfa..a31c8c4654035537e3e7453258847247245b291a 100644
--- a/scipost/admin.py
+++ b/scipost/admin.py
@@ -8,7 +8,7 @@ from django import forms
 from django.contrib.auth.admin import UserAdmin
 from django.contrib.auth.models import User, Permission
 
-from scipost.models import Contributor, Remark,\
+from scipost.models import Contributor, Remark, TOTPDevice,\
                            AuthorshipClaim, PrecookedEmail,\
                            EditorialCollege, EditorialCollegeFellowship, UnavailabilityPeriod
 
@@ -19,6 +19,8 @@ from submissions.models import Submission
 
 admin.site.register(UnavailabilityPeriod)
 
+admin.site.register(TOTPDevice)
+
 
 class ContributorAdmin(admin.ModelAdmin):
     search_fields = [
diff --git a/scipost/forms.py b/scipost/forms.py
index c5b06972a22572bace01af46d4dd458799f410ab..6c5e19c21992dbfab0e3bae53cdcf8f688f8082b 100644
--- a/scipost/forms.py
+++ b/scipost/forms.py
@@ -27,7 +27,8 @@ from .constants import (
 from .decorators import has_contributor
 from .fields import ReCaptchaField
 from .models import Contributor, DraftInvitation, UnavailabilityPeriod, \
-    Remark, AuthorshipClaim, PrecookedEmail
+    Remark, AuthorshipClaim, PrecookedEmail, TOTPDevice
+from .totp import TOTPVerification
 
 from affiliations.models import Affiliation, Institution
 from common.forms import MonthYearWidget, ModelChoiceFieldwithid
@@ -301,23 +302,51 @@ class SciPostAuthenticationForm(AuthenticationForm):
     - confirm_login_allowed: disallow inactive or unvetted accounts.
     """
     next = forms.CharField(widget=forms.HiddenInput(), required=False)
+    token = forms.CharField(
+        required=False,
+        help_text="Please type in the code displayed on your authenticator app from your device")
 
     def confirm_login_allowed(self, user):
         if not user.is_active:
             raise forms.ValidationError(
-                _('Your account is not yet activated. '
+                ('Your account is not yet activated. '
                   'Please first activate your account by clicking on the '
                   'activation link we emailed you.'),
                 code='inactive',
                 )
         if not user.groups.exists():
             raise forms.ValidationError(
-                _('Your account has not yet been vetted.\n'
+                ('Your account has not yet been vetted.\n'
                   'Our admins will verify your credentials very soon, '
                   'and if vetted (your will receive an information email) '
                   'you will then be able to login.'),
                 code='unvetted',
                 )
+        if user.devices.exists():
+            if self.cleaned_data.get('token'):
+                token = self.cleaned_data.get('token')
+                totp = TOTPVerification(user)
+                if not totp.verify_token(token):
+                    self.add_error('token', 'Invalid token')
+            else:
+                self.add_error('token', 'Your account uses two factor authentication')
+
+
+class UserAuthInfoForm(forms.Form):
+    username = forms.CharField()
+
+    def get_data(self):
+        username = self.cleaned_data.get('username')
+        return {
+            'username': username,
+            'has_password': True,
+            'has_totp': TOTPDevice.objects.filter(user__username=username).exists()
+        }
+
+
+class TOTPDeviceForm(forms.Form):
+    token = forms.CharField()
+    key = forms.CharField(widget=forms.HiddenInput(), required=True)
 
 
 AUTHORSHIP_CLAIM_CHOICES = (
diff --git a/scipost/migrations/0023_totpdevice.py b/scipost/migrations/0023_totpdevice.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ebf10329c9f67b041847d9e1523ced8045bfcc2
--- /dev/null
+++ b/scipost/migrations/0023_totpdevice.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2019-03-25 12:17
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('scipost', '0022_auto_20190127_2021'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='TOTPDevice',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=128)),
+                ('token', models.CharField(max_length=16)),
+                ('last_verified_counter', models.PositiveIntegerField(default=0)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('modified', models.DateTimeField(auto_now=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'default_related_name': 'devices',
+            },
+        ),
+    ]
diff --git a/scipost/models.py b/scipost/models.py
index e945aa6ee61ea6b389a6cb8ec91a45276cca4913..9f5067f35cc28417b76b130068f5c83944f8599a 100644
--- a/scipost/models.py
+++ b/scipost/models.py
@@ -38,6 +38,25 @@ def get_sentinel_user():
     return Contributor.objects.get_or_create(status=DISABLED, user=user)[0]
 
 
+class TOTPDevice(models.Model):
+    """
+    Any device used by a User for 2-step authentication based on the RFC 6238 TOTP protocol.
+    """
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    name = models.CharField(max_length=128)
+    token = models.CharField(max_length=16)
+    last_verified_counter = models.PositiveIntegerField(default=0)
+
+    created = models.DateTimeField(auto_now_add=True)
+    modified = models.DateTimeField(auto_now=True)
+
+    class Meta:
+        default_related_name = 'devices'
+
+    def __str__(self):
+        return '{}: {}'.format(self.user, self.name)
+
+
 class Contributor(models.Model):
     """A Contributor is an academic extention of the User model.
 
diff --git a/scipost/static/scipost/assets/js/scripts.js b/scipost/static/scipost/assets/js/scripts.js
index e67b3aecdde12d71e15231093b12ff8a3d6936ec..24ed0c9bd8d2a279d5c8a61082139d5f327b35e3 100644
--- a/scipost/static/scipost/assets/js/scripts.js
+++ b/scipost/static/scipost/assets/js/scripts.js
@@ -1,5 +1,6 @@
 require('jquery-ui/ui/widgets/sortable');
 require('jquery-ui/ui/disable-selection');
+var QRCode = require('qrcode');
 
 import notifications from './notifications.js';
 
@@ -15,6 +16,16 @@ var activate_tooltip = function() {
     });
 }
 
+var activate_qr = function() {
+    $.each($('[data-toggle="qr"]'), function(index, value) {
+        var el = $(value);
+        console.log(el.data('qr-value'));
+        QRCode.toCanvas(el, el.data('qr-value'), function(err) {
+            console.log(err);
+        })
+    });
+};
+
 
 var select_form_table = function(table_el) {
     $(table_el + ' tbody tr input[type="checkbox"]').on('change', function() {
@@ -78,6 +89,7 @@ function init_page() {
     });
 
     activate_tooltip();
+    activate_qr();
     sort_form_list('form ul.sortable-list');
     sort_form_list('table.sortable-rows > tbody');
     select_form_table('.table-selectable');
diff --git a/scipost/templates/partials/scipost/personal_page/account.html b/scipost/templates/partials/scipost/personal_page/account.html
index 164aac577ebe00b85d1662a9edd084502dec7aeb..27a9a7fabf5cadf15ee3a4d07e99d841f686dcba 100644
--- a/scipost/templates/partials/scipost/personal_page/account.html
+++ b/scipost/templates/partials/scipost/personal_page/account.html
@@ -142,6 +142,7 @@
       <ul>
         <li><a href="{% url 'scipost:update_personal_data' %}">Update your personal data</a></li>
         <li><a href="{% url 'scipost:password_change' %}">Change your password</a></li>
+        <li><a href="{% url 'scipost:totp' %}">Two factor authentication</a></li>
       </ul>
     </div>
 </div>
diff --git a/scipost/templates/scipost/login.html b/scipost/templates/scipost/login.html
index 93f0070cf817bb934975c42d62a8ae2ef18a5997..de6705c4120f0df11cb7c7c966c5c2810534169a 100644
--- a/scipost/templates/scipost/login.html
+++ b/scipost/templates/scipost/login.html
@@ -28,3 +28,33 @@
 </div>
 
 {% endblock %}
+
+
+{% block footer_script %}
+
+<script type="text/javascript">
+    $(function() {
+        $('[name="token"]').parents('.form-group').hide();  // Just to prevent having annoying animations.
+        $('form [name="username"]').on('change', function() {
+            $.ajax({
+                type: 'POST',
+                url: '{% url "scipost:login_info" %}',
+                data: {
+                    csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
+                    username: $('form [name="username"]').val(),
+                },
+                dataType: 'json',
+                processData: true,
+                success: function(data) {
+                    if (data.has_totp) {
+                        $('[name="token"]').parents('.form-group').show();
+                    } else {
+                        $('[name="token"]').parents('.form-group').hide();
+                    }
+                }
+            });
+        }).trigger('change');
+    });
+</script>
+
+{% endblock %}
diff --git a/scipost/templates/scipost/totpdevice_confirm_delete.html b/scipost/templates/scipost/totpdevice_confirm_delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..c3e54f375b2a36e5c35fd3b5197105f9fc9a7a8b
--- /dev/null
+++ b/scipost/templates/scipost/totpdevice_confirm_delete.html
@@ -0,0 +1,33 @@
+{% extends 'scipost/_personal_page_base.html' %}
+
+{% block pagetitle %}: Delete device{% endblock pagetitle %}
+
+{% load bootstrap %}
+
+{% block breadcrumb_items %}
+    {{block.super}}
+    <a href="{% url 'scipost:totp' %}" class="breadcrumb-item">Two factor authentication</a>
+    <span class="breadcrumb-item">Delete {{ object.name }}</span>
+{% endblock %}
+
+
+{% block content %}
+
+<div class="row">
+    <div class="col-md-12">
+      <h1 class="highlight">Delete two factor authentication device</h1>
+    <br>
+      <form method="post">
+        {% csrf_token %}
+        <h3 class="mb-2"><p>Are you sure you want to delete this device?</h3>
+        <ul>
+            <li>Name: {{ object.name }}</li>
+            <li>Last used: {{ object.modified }}</li>
+        </ul>
+        <input type="submit" class="btn btn-danger" value="Yes, delete it" />
+      </form>
+    </div>
+
+</div>
+
+{% endblock %}
diff --git a/scipost/templates/scipost/totpdevice_form.html b/scipost/templates/scipost/totpdevice_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..6725b7c11089b4824762b5879b6d33dcfd458346
--- /dev/null
+++ b/scipost/templates/scipost/totpdevice_form.html
@@ -0,0 +1,51 @@
+{% extends 'scipost/_personal_page_base.html' %}
+
+{% block pagetitle %}: New device{% endblock pagetitle %}
+
+{% load bootstrap %}
+
+{% block breadcrumb_items %}
+    {{block.super}}
+    <a href="{% url 'scipost:totp' %}" class="breadcrumb-item">Two factor authentication</a>
+    <span class="breadcrumb-item">New device</span>
+{% endblock %}
+
+
+{% block content %}
+
+<div class="row">
+    <div class="col-md-12">
+      <h1 class="highlight">Set up two factor authentication device</h1>
+
+    <p>
+        An authenticator app lets you generate security codes on your phone without needing to receive text messages. If you don’t already have one, we support any of these apps.
+        <br>
+        To configure your authenticator app:
+    </p>
+    <ul>
+        <li>Add a new time-based token.</li>
+        <li>Use your app to scan the barcode below, or enter your secret key manually.</li>
+    </ul>
+      <form method="post">
+        {% csrf_token %}
+        <p>
+            Enter the security code generated by your mobile authenticator app to make sure it’s configured correctly.
+        </p>
+        <canvas id="qr" data-toggle="qr" data-qr-value="blabla"></canvas>
+        <!-- <script>
+          (function() {
+            var qr = new QRious({
+              element: document.getElementById('qr'),
+              value: 'https://github.com/neocotic/qrious'
+            });
+          })();
+        </script> -->
+
+          {{ form|bootstrap }}
+        <input type="submit" class="btn btn-primary" value="Check" />
+      </form>
+    </div>
+
+</div>
+
+{% endblock %}
diff --git a/scipost/templates/scipost/totpdevice_list.html b/scipost/templates/scipost/totpdevice_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..61cc4f55437e6dbb18f4079965b62ef3968b236d
--- /dev/null
+++ b/scipost/templates/scipost/totpdevice_list.html
@@ -0,0 +1,44 @@
+{% extends 'scipost/_personal_page_base.html' %}
+
+{% block pagetitle %}: Two factor authentication{% endblock pagetitle %}
+
+{% load bootstrap %}
+
+{% block breadcrumb_items %}
+    {{block.super}}
+    <span class="breadcrumb-item">Two factor authentication</span>
+{% endblock %}
+
+{% block content %}
+
+<div class="row">
+    <div class="col-md-12">
+      <h1 class="highlight">Two factor authentication</h1>
+      <a href="{% url 'scipost:totp_create' %}">Set up a new two factor authentication device</a>
+
+      <h3 class="mt-4">Your devices</h3>
+      <table class="table">
+          <thead>
+              <tr>
+                  <th>Name</th>
+                  <th>Last used</th>
+                  <th></th>
+              </tr>
+          </thead>
+          <tbody>
+              {% for device in object_list %}
+                  <tr>
+                      <td>{{ device.name }}</td>
+                      <td>{{ device.modified }}</td>
+                      <td>
+                          <a class="text-danger" href="{% url 'scipost:totp_delete' device.id %}">Remove device</a>
+                      </td>
+                  </tr>
+              {% endfor %}
+          </tbody>
+      </table>
+    </div>
+
+</div>
+
+{% endblock %}
diff --git a/scipost/totp.py b/scipost/totp.py
new file mode 100644
index 0000000000000000000000000000000000000000..083d813aa90fb83ad9d9426f8b50ae52af9feccf
--- /dev/null
+++ b/scipost/totp.py
@@ -0,0 +1,52 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+import pyotp
+
+from time import time
+
+from .models import TOTPDevice
+
+
+class TOTPVerification:
+
+    def __init__(self, user):
+        """
+        Initiate for a certain user instance.
+        """
+        self._user = user
+
+        # Next token must be generated at a higher counter value.
+        self.number_of_digits = 6
+        self.token_validity_period = 30
+        self.tolerance = 2  # Gives a 2 minute window to use a code.
+
+    def verify_token(self, token, tolerance=0):
+        try:
+            # Convert the input token to integer
+            token = int(token)
+        except ValueError:
+            # return False, if token could not be converted to an integer
+            return False
+        else:
+            if not hasattr(self._user, 'devices'):
+                # For example non-authenticated users...
+                return False
+
+            for device in self._user.devices.all():
+                time_int = int(time())
+                totp = pyotp.TOTP(
+                    device.token, interval=self.token_validity_period, digits=self.number_of_digits)
+
+                # 1. Check if the current counter is higher than the value of last verified counter
+                # 2. Check if entered token is correct
+                valid_token = totp.verify(token, for_time=time_int, valid_window=self.tolerance)
+                if not valid_token:
+                    # Token not valid
+                    continue
+                elif device.last_verified_counter <= 0 or time_int > device.last_verified_counter:
+                    # If the condition is true, set the last verified counter value
+                    # to current counter value, and return True
+                    TOTPDevice.objects.filter(id=device.id).update(last_verified_counter=time_int)
+                    return True
+        return False
diff --git a/scipost/urls.py b/scipost/urls.py
index f418c17fdec1601269372213c5fabd52df337603..ea0082031fd7760f5ee7c624181926cf2f583690 100644
--- a/scipost/urls.py
+++ b/scipost/urls.py
@@ -113,6 +113,11 @@ urlpatterns = [
         views.SciPostLoginView.as_view(),
         name='login'
     ),
+    url(
+        r'^login/info/$',
+        views.raw_user_auth_info,
+        name='login_info'
+    ),
     url(
         r'^logout/$',
         views.SciPostLogoutView.as_view(),
@@ -134,6 +139,9 @@ urlpatterns = [
         name='password_reset_confirm'
     ),
     url(r'^update_personal_data$', views.update_personal_data, name='update_personal_data'),
+    url(r'^totp/$', views.TOTPListView.as_view(), name='totp'),
+    url(r'^totp/create$', views.TOTPDeviceCreateView.as_view(), name='totp_create'),
+    url(r'^totp/(?P<device_id>[0-9]+)/delete$', views.TOTPDeviceDeleteView.as_view(), name='totp_delete'),
 
     # Personal Page
     url(r'^personal_page/$', views.personal_page, name='personal_page'),
diff --git a/scipost/views.py b/scipost/views.py
index b1629110da960af1a7a5e2312fcfa05b13148841..5ef1df8e766c1be6fa493ea38d5b0ce43867451d 100644
--- a/scipost/views.py
+++ b/scipost/views.py
@@ -21,15 +21,16 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives
 from django.core.paginator import Paginator
 from django.core.urlresolvers import reverse, reverse_lazy
 from django.db import transaction
-from django.http import Http404
+from django.http import Http404, JsonResponse
 from django.shortcuts import redirect
 from django.template import Context, Template
 from django.utils.decorators import method_decorator
 from django.utils.http import is_safe_url
+from django.views.debug import cleanse_setting
 from django.views.decorators.cache import never_cache
 from django.views.decorators.http import require_POST
+from django.views.generic.edit import FormView, DeleteView
 from django.views.generic.list import ListView
-from django.views.debug import cleanse_setting
 from django.views.static import serve
 
 from guardian.decorators import permission_required
@@ -41,7 +42,7 @@ from .constants import (
 from .decorators import has_contributor, is_contributor_user
 from .models import Contributor, UnavailabilityPeriod, AuthorshipClaim, EditorialCollege
 from .forms import (
-    SciPostAuthenticationForm,
+    SciPostAuthenticationForm, UserAuthInfoForm, TOTPDeviceForm,
     UnavailabilityPeriodForm, RegistrationForm, AuthorshipClaimForm,
     SearchForm, VetRegistrationForm, reg_ref_dict, UpdatePersonalDataForm, UpdateUserDataForm,
     ContributorMergeForm,
@@ -434,6 +435,15 @@ class SciPostLoginView(LoginView):
             return reverse_lazy('scipost:index')
 
 
+def raw_user_auth_info(request):
+    form = UserAuthInfoForm(request.POST or None)
+
+    data = {}
+    if form.is_valid():
+        data = form.get_data()
+    return JsonResponse(data)
+
+
 class SciPostLogoutView(LogoutView):
     """Logout processing page."""
 
@@ -871,6 +881,25 @@ def update_personal_data(request):
     return _update_personal_data_user_only(request)
 
 
+class TOTPListView(ListView):
+    def get_queryset(self):
+        return self.request.user.devices.all()
+
+
+class TOTPDeviceCreateView(FormView):
+    form_class = TOTPDeviceForm
+    template_name = 'scipost/totpdevice_form.html'
+    success_url = reverse_lazy('scipost:totp')
+
+
+class TOTPDeviceDeleteView(DeleteView):
+    pk_url_kwarg = 'device_id'
+    success_url = reverse_lazy('scipost:totp')
+
+    def get_queryset(self):
+        return self.request.user.devices.all()
+
+
 @login_required
 @is_contributor_user()
 def claim_authorships(request):