diff --git a/migrations/versions/a8c6b6e91c28_device_login.py b/migrations/versions/a8c6b6e91c28_device_login.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d594b4afc7903cc09abcf1c30cabe69264f3ca2
--- /dev/null
+++ b/migrations/versions/a8c6b6e91c28_device_login.py
@@ -0,0 +1,45 @@
+"""device login
+
+Revision ID: a8c6b6e91c28
+Revises: bad6fc529510
+Create Date: 2021-07-19 14:37:02.559667
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'a8c6b6e91c28'
+down_revision = 'bad6fc529510'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	op.create_table('device_login_initiation',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False),
+		sa.Column('code0', sa.String(length=32), nullable=False),
+		sa.Column('code1', sa.String(length=32), nullable=False),
+		sa.Column('secret', sa.String(length=128), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=False),
+		sa.Column('oauth2_client_id', sa.String(length=40), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_initiation')),
+		sa.UniqueConstraint('code0', name=op.f('uq_device_login_initiation_code0')),
+		sa.UniqueConstraint('code1', name=op.f('uq_device_login_initiation_code1'))
+	)
+	op.create_table('device_login_confirmation',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('initiation_id', sa.Integer(), nullable=False),
+		sa.Column('user_dn', sa.String(length=128), nullable=False),
+		sa.Column('code0', sa.String(length=32), nullable=False),
+		sa.Column('code1', sa.String(length=32), nullable=False),
+		sa.ForeignKeyConstraint(['initiation_id'], ['device_login_initiation.id'], name=op.f('fk_device_login_confirmation_initiation_id_device_login_initiation')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_confirmation')),
+		sa.UniqueConstraint('initiation_id', 'code0', name=op.f('uq_device_login_confirmation_initiation_id_code0')),
+		sa.UniqueConstraint('initiation_id', 'code1', name=op.f('uq_device_login_confirmation_initiation_id_code1')),
+		sa.UniqueConstraint('user_dn', name=op.f('uq_device_login_confirmation_user_dn'))
+	)
+
+def downgrade():
+	op.drop_table('device_login_confirmation')
+	op.drop_table('device_login_initiation')
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index b77a09925ed7702325c91c4b3e0a6ed694cb14e5..13d8b87ed8ac993a7730e11f8c39d8756c8fba3e 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -1,13 +1,14 @@
 import datetime
 from urllib.parse import urlparse, parse_qs
 
-from flask import url_for
+from flask import url_for, session
 
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
 from uffd.user.models import User
-from uffd.oauth2.models import OAuth2Client
+from uffd.session.models import DeviceLoginConfirmation
+from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
 from uffd import create_app, db, ldap
 
 from utils import dump, UffdTestCase
@@ -52,11 +53,7 @@ class TestViews(UffdTestCase):
 			'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'},
 		}
 
-	def test_authorization(self):
-		self.client.post(path=url_for('session.login'),
-			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
-		state = 'teststate'
-		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state=state, redirect_uri='http://localhost:5009/callback'), follow_redirects=False)
+	def assert_authorization(self, r):
 		while True:
 			if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'):
 				break
@@ -64,7 +61,7 @@ class TestViews(UffdTestCase):
 		self.assertEqual(r.status_code, 302)
 		self.assertTrue(r.location.startswith('http://localhost:5009/callback'))
 		args = parse_qs(urlparse(r.location).query)
-		self.assertEqual(args['state'], [state])
+		self.assertEqual(args['state'], ['teststate'])
 		code = args['code'][0]
 		r = self.client.post(path=url_for('oauth2.token'),
 			data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
@@ -83,5 +80,33 @@ class TestViews(UffdTestCase):
 		self.assertEqual(r.json['email'], user.mail)
 		self.assertTrue(r.json.get('groups'))
 
-class TestViewsOL(TestViews):
-	use_openldap = True
+	def test_authorization(self):
+		self.client.post(path=url_for('session.login'),
+			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
+		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback'), follow_redirects=False)
+		self.assert_authorization(r)
+
+	def test_authorization_devicelogin_start(self):
+		ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback')
+		r = self.client.get(path=url_for('session.devicelogin_start', ref=ref), follow_redirects=True)
+		# check response
+		initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one()
+		self.assertEqual(r.status_code, 200)
+		self.assertFalse(initiation.expired)
+		self.assertEqual(initiation.oauth2_client_id, 'test')
+		self.assertIsNotNone(initiation.description)
+
+	def test_authorization_devicelogin_auth(self):
+		with self.client.session_transaction() as _session:
+			initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test')
+			db.session.add(initiation)
+			confirmation = DeviceLoginConfirmation(initiation=initiation, user=get_user())
+			db.session.add(confirmation)
+			db.session.commit()
+			_session['devicelogin_id'] = initiation.id
+			_session['devicelogin_secret'] = initiation.secret
+			code = confirmation.code
+		self.client.get(path='/')
+		ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback')
+		r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=False)
+		self.assert_authorization(r)
diff --git a/tests/test_session.py b/tests/test_session.py
index 0882b08e16d895cc474e951488a312ba88c74e14..74acf489ba6f83998909ec1fbf136e9f9c44b4c1 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -7,6 +7,8 @@ from flask import url_for, request
 from uffd import ldap, user
 
 from uffd.session.views import login_required
+from uffd.session.models import DeviceLoginConfirmation
+from uffd.oauth2.models import OAuth2DeviceLoginInitiation
 from uffd import create_app, db
 
 from utils import dump, UffdTestCase
@@ -133,6 +135,33 @@ class TestSession(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertIsNone(request.user)
 
+	def test_deviceauth(self):
+		self.app.config['OAUTH2_CLIENTS'] = {
+			'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']},
+		}
+		initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test')
+		db.session.add(initiation)
+		db.session.commit()
+		code = initiation.code
+		self.login()
+		r = self.client.get(path=url_for('session.deviceauth'), follow_redirects=True)
+		dump('deviceauth', r)
+		self.assertEqual(r.status_code, 200)
+		r = self.client.get(path=url_for('session.deviceauth', **{'initiation-code': code}), follow_redirects=True)
+		dump('deviceauth_check', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn(b'test', r.data)
+		r = self.client.post(path=url_for('session.deviceauth_submit'), data={'initiation-code': code}, follow_redirects=True)
+		dump('deviceauth_submit', r)
+		self.assertEqual(r.status_code, 200)
+		initiation = OAuth2DeviceLoginInitiation.query.filter_by(code=code).one()
+		self.assertEqual(len(initiation.confirmations), 1)
+		self.assertEqual(initiation.confirmations[0].user.loginname, 'testuser')
+		self.assertIn(initiation.confirmations[0].code.encode(), r.data)
+		r = self.client.get(path=url_for('session.deviceauth_finish'), follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(DeviceLoginConfirmation.query.all(), [])
+
 class TestSessionOL(TestSession):
 	use_openldap = True
 
diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py
index 162ac321f33fd9a9c68ce60047bf6de7d151c586..c56ba0bd9c430bc7cd26e2caf4f4d9d2d38466b1 100644
--- a/uffd/oauth2/models.py
+++ b/uffd/oauth2/models.py
@@ -4,6 +4,7 @@ from ldapalchemy.dbutils import DBRelationship
 
 from uffd.database import db
 from uffd.user.models import User
+from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
 
 class OAuth2Client:
 	def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None):
@@ -99,3 +100,17 @@ class OAuth2Token(db.Model):
 		db.session.delete(self)
 		db.session.commit()
 		return self
+
+class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
+	__mapper_args__ = {
+		'polymorphic_identity': DeviceLoginType.OAUTH2
+	}
+	oauth2_client_id = Column(String(40))
+
+	@property
+	def oauth2_client(self):
+		return OAuth2Client.from_id(self.oauth2_client_id)
+
+	@property
+	def description(self):
+		return self.oauth2_client.client_id
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index 12e62febde73d9f22b56a0b2620b0222535fffee..30957deb14556cc99ee87977ba784515c8ebaf27 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -2,13 +2,14 @@ import datetime
 import functools
 import urllib.parse
 
-from flask import Blueprint, request, jsonify, render_template, session, redirect
-
+from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash
 from flask_oauthlib.provider import OAuth2Provider
+from sqlalchemy.exc import IntegrityError
 
+from uffd.ratelimit import host_ratelimit, format_delay
 from uffd.database import db
-from uffd.session.views import login_required
-from .models import OAuth2Client, OAuth2Grant, OAuth2Token
+from uffd.session.models import DeviceLoginConfirmation
+from .models import OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
 
 oauth = OAuth2Provider()
 
@@ -23,7 +24,7 @@ def load_grant(client_id, code):
 @oauth.grantsetter
 def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
 	expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
-	grant = OAuth2Grant(user_dn=request.user.dn, client_id=client_id,
+	grant = OAuth2Grant(user_dn=request.oauth2_user.dn, client_id=client_id,
 		code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
 	db.session.add(grant)
 	db.session.commit()
@@ -78,18 +79,52 @@ def inject_scope(func):
 	return decorator
 
 @bp.route('/authorize', methods=['GET', 'POST'])
-@login_required()
 @inject_scope
 @oauth.authorize_handler
 def authorize(*args, **kwargs): # pylint: disable=unused-argument
+	client = kwargs['request'].client
+	request.oauth2_user = None
+	if request.user:
+		request.oauth2_user = request.user
+	elif 'devicelogin_started' in session:
+		del session['devicelogin_started']
+		host_delay = host_ratelimit.get_delay()
+		if host_delay:
+			flash('We received too many requests from your ip address/network! Please wait at least %s.'%format_delay(host_delay))
+			return redirect(url_for('session.login', ref=request.url, devicelogin=True))
+		host_ratelimit.log()
+		initiation = OAuth2DeviceLoginInitiation(oauth2_client_id=client.client_id)
+		db.session.add(initiation)
+		try:
+			db.session.commit()
+		except IntegrityError:
+			flash('Device login is currently not available. Try again later!')
+			return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+		session['devicelogin_id'] = initiation.id
+		session['devicelogin_secret'] = initiation.secret
+		return redirect(url_for('session.devicelogin', ref=request.url))
+	elif 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session:
+		initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret'],
+		                                                         oauth2_client_id=client.client_id).one_or_none()
+		confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation'])
+		del session['devicelogin_id']
+		del session['devicelogin_secret']
+		del session['devicelogin_confirmation']
+		if not initiation or initiation.expired or not confirmation:
+			flash('Device login failed')
+			return redirect(url_for('session.login', ref=request.url, devicelogin=True))
+		request.oauth2_user = confirmation.user
+		db.session.delete(initiation)
+		db.session.commit()
+	else:
+		return redirect(url_for('session.login', ref=request.url, devicelogin=True))
 	# Here we would normally ask the user, if he wants to give the requesting
 	# service access to his data. Since we only have trusted services (the
 	# clients defined in the server config), we don't ask for consent.
-	client = kwargs['request'].client
 	session['oauth2-clients'] = session.get('oauth2-clients', [])
 	if client.client_id not in session['oauth2-clients']:
 		session['oauth2-clients'].append(client.client_id)
-	return client.access_allowed(request.user)
+	return client.access_allowed(request.oauth2_user)
 
 @bp.route('/token', methods=['GET', 'POST'])
 @oauth.token_handler
diff --git a/uffd/session/models.py b/uffd/session/models.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d515395f66f815b94e216c39893ea00f5b24e433 100644
--- a/uffd/session/models.py
+++ b/uffd/session/models.py
@@ -0,0 +1,142 @@
+import datetime
+import secrets
+import math
+import enum
+
+from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum
+from sqlalchemy.orm import relationship
+from sqlalchemy.ext.hybrid import hybrid_property
+from ldapalchemy.dbutils import DBRelationship
+
+from uffd.database import db
+from uffd.user.models import User
+
+def token_typeable(nbytes=None):
+	'''Return random text token that is easy to type (on mobile)'''
+	alphabet = '123456789abcdefghkmnopqrstuvwx' # No '0ijlyz'
+	if nbytes is None:
+		nbytes = secrets.DEFAULT_ENTROPY
+	nbytes_per_char = math.log(len(alphabet), 256)
+	nchars = math.ceil(nbytes / nbytes_per_char)
+	return ''.join([secrets.choice(alphabet) for _ in range(nchars)])
+
+# Device login provides a convenient and secure way to log into SSO-enabled
+# services on a secondary device without entering the user password or
+# completing 2FA challenges.
+#
+# Use-cases:
+# * A user wants to log into a single OAuth2-enabled web service on his
+#   mobile phone without trusting the device enough to expose his full
+#   credentials.
+# * A user wants to log into an OAuth2-enabled web service on a secondary
+#   device at a busy event location with too little privacy to securly enter
+#   his credentials but already has a login session on his laptop.
+# * A user wants to log into an OAuth2-enabled service via the web browser
+#   on a native mobile app on his phone and cannot use his 2FA method on that
+#   device (e.g. FIDO2 token with USB-A) or in the app's web view.
+
+# The mechanism uses two random codes: When the user attempts to authenticate
+# with an SSO-enabled service and chooses the "Device Login" option on the SSO
+# login page, the SSO generates and displays an initiation code. That code is
+# securly bound to the browser session that is used to request it. The user
+# logs into the SSO on another device using his credentials and 2FA methods and
+# opens a page to authorize the device login attempt. There he enteres the
+# initiation code. The SSO displays the details of the device login attempt
+# (i.e. the name of the service to log into). Once the user authorizes the
+# login attempt, the SSO generates a confirmation code and displays it to the
+# user. The user enters the confirmation code on the device he wants to log
+# in with and proceeds with the authentication.
+#
+# An attacker might
+# * generate initiation codes,
+# * observe the displayed/entered initiation code,
+# * observe the displayed/entered confirmation code and
+# * possibly divert the victims attention and provoke typing errors.
+#
+# An attacker must not be able to
+# * authenticate with an SSO-enabled service as another user or
+# * trick a user to authenticate with an SSO-enabled service as the attacker.
+#
+# An example for the second case would be the Nextcloud mobile app: The app
+# integrates closely with the phone's OS and provides features like
+# auto-upload of photos, contacts and more. If the app would authenticate
+# with an attacker-controlled account, the attacker would have access to
+# this data.
+
+class DeviceLoginType(enum.Enum):
+	OAUTH2 = 0
+
+class DeviceLoginInitiation(db.Model):
+	'''Abstract initiation code class
+
+	An initiation code is generated and displayed when a user chooses
+	"Device Login" on the login page. Instances are always bound to a
+	specific service, e.g. a client id in case of OAuth2.
+
+	The code attribute is formed out of two indepentently unique parts
+	to ensure that at any time all existing codes differ in at least two
+	characters (i.e. mistyping one character can not result in another
+	existing and possibly attacker-controlled code).
+
+	An initiation code is securly bound to the session that it was created
+	with by storing both id and secret in the encrypted and authenticated
+	session cookie.'''
+	__tablename__ = 'device_login_initiation'
+
+	id = Column(Integer(), primary_key=True, autoincrement=True)
+	type = Column(Enum(DeviceLoginType), nullable=False)
+	code0 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
+	code1 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
+	secret = Column(String(128), nullable=False, default=lambda: secrets.token_hex(64))
+	confirmations = relationship('DeviceLoginConfirmation', back_populates='initiation', cascade='all, delete-orphan')
+	created = Column(DateTime, default=datetime.datetime.now, nullable=False)
+
+	__mapper_args__ = {
+		'polymorphic_on': type,
+	}
+
+	@hybrid_property
+	def code(self):
+		# Split into two parts, each unique, to ensure that every code differs
+		# in more than one character from other existing codes.
+		return self.code0 + self.code1
+
+	@hybrid_property
+	def expired(self):
+		return self.created < datetime.datetime.now() - datetime.timedelta(minutes=30)
+
+	@property
+	def description(self):
+		raise NotImplementedError()
+
+class DeviceLoginConfirmation(db.Model):
+	'''Confirmation code class
+
+	A confirmation code is generated and displayed when an authenticated user
+	enters an initiation code and confirms the device login attempt. Every
+	instance is bound to both an initiation code and a user.
+
+	The code attribute is formed out of two indepentently unique parts
+	to ensure that at any time all existing codes differ in at least two
+	characters (i.e. mistyping one character can not result in another
+	existing and possibly attacker-controlled code).'''
+	__tablename__ = 'device_login_confirmation'
+
+	id = Column(Integer(), primary_key=True, autoincrement=True)
+	initiation_id = Column(Integer(), ForeignKey('device_login_initiation.id'), nullable=False)
+	initiation = relationship('DeviceLoginInitiation', back_populates='confirmations')
+	user_dn = Column(String(128), nullable=False, unique=True)
+	user = DBRelationship('user_dn', User)
+	code0 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
+	code1 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
+
+	__table_args__ = (
+		db.UniqueConstraint('initiation_id', 'code0', name='uq_device_login_confirmation_initiation_id_code0'),
+		db.UniqueConstraint('initiation_id', 'code1', name='uq_device_login_confirmation_initiation_id_code1'),
+	)
+
+	@hybrid_property
+	def code(self):
+		# Split into two parts, each unique, to ensure that every code differs
+		# in more than one character from other existing codes.
+		return self.code0 + self.code1
diff --git a/uffd/session/templates/session/deviceauth.html b/uffd/session/templates/session/deviceauth.html
new file mode 100644
index 0000000000000000000000000000000000000000..09eb76f2501bc7e16f52385c244eedc21e12ace6
--- /dev/null
+++ b/uffd/session/templates/session/deviceauth.html
@@ -0,0 +1,67 @@
+{% extends 'base.html' %}
+
+{% block body %}
+{% if not initiation %}
+<form action="{{ url_for("session.deviceauth") }}">
+{% elif not confirmation %}
+<form action="{{ url_for("session.deviceauth_submit") }}" method="POST">
+{% else %}
+<form action="{{ url_for("session.deviceauth_finish") }}" method="POST">
+{% endif %}
+<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 alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
+		</div>
+		<div class="col-12">
+			<h2 class="text-center">Authorize Device Login</h2>
+		</div>
+		<div class="form-group col-12">
+			<p>Log into a service on another device without entering your password.</p>
+		</div>
+		<div class="form-group col-12">
+			<label for="initiation-code">Initiation Code</label>
+			{% if not initiation %}
+				<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation_code or '' }}" required="required" tabindex = "1" autofocus>
+			{% else %}
+				<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation.code }}" readonly>
+			{% endif %}
+		</div>
+		{% if confirmation %}
+			<div class="form-group col-12">
+				<label for="confirmation-code">Confirmation Code</label>
+				<input type="text" class="form-control" id="confirmation-code" name="confirmation-code" value="{{ confirmation.code }}" readonly>
+			</div>
+		{% endif %}
+		{% if not initiation %}
+			<div class="form-group col-12">
+				<p>Start logging into a service on the other device and chose "Device Login" on the login page. Enter the displayed initiation code in the box above.</p>
+			</div>
+			<div class="form-group col-12">
+				<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Continue</button>
+			</div>
+			<div class="form-group col-12">
+				<a href="{{ url_for('index') }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
+			</div>
+		{% elif not confirmation %}
+			<div class="form-group col-12">
+				<p>Authorize the login for service <b>{{ initiation.description }}</b>?</p>
+			</div>
+			<div class="form-group col-12">
+				<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Authorize Login</button>
+			</div>
+			<div class="form-group col-12">
+				<a href="{{ url_for('index') }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
+			</div>
+		{% else %}
+			<div class="form-group col-12">
+				<p>Enter the confirmation code on the other device and complete the login. Click <em>Finish</em> afterwards.</p>
+			</div>
+			<div class="form-group col-12">
+				<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Finish</button>
+			</div>
+		{% endif %}
+	</div>
+</div>
+</form>
+{% endblock %}
diff --git a/uffd/session/templates/session/devicelogin.html b/uffd/session/templates/session/devicelogin.html
new file mode 100644
index 0000000000000000000000000000000000000000..4bd92b15971675db671bab48b860246a2cc53401
--- /dev/null
+++ b/uffd/session/templates/session/devicelogin.html
@@ -0,0 +1,39 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<form action="{{ url_for("session.devicelogin_submit", 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 alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
+		</div>
+		<div class="col-12">
+			<h2 class="text-center">Device Login</h2>
+		</div>
+		<div class="form-group col-12">
+			<p>Use a login session on another device (e.g. your laptop) to log into a service without entering your password.</p>
+		</div>
+		{% if initiation %}
+		<div class="form-group col-12">
+			<label for="initiation-code">Initiation Code</label>
+			<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation.code }}" readonly>
+		</div>
+		<input type="hidden" class="form-control" id="initiation-secret" name="initiation-secret" value="{{ initiation.secret }}">
+		<div class="form-group col-12">
+			<label for="confirmation-code">Confirmation Code</label>
+			<input type="text" class="form-control" id="confirmation-code" name="confirmation-code" required="required" tabindex = "1" autofocus>
+		</div>
+		<div class="form-group col-12">
+			<p>Open <code><a href="{{ url_for('session.deviceauth') }}">{{ url_for('session.deviceauth', _external=True) }}</a></code> on the other device and enter the initiation code there. Then enter the confirmation code in the box above.</p>
+		</div>
+		<div class="form-group col-12">
+			<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Continue</button>
+		</div>
+		{% endif %}
+		<div class="form-group col-12">
+			<a href="{{ url_for('session.login', ref=ref, devicelogin=True) }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
+		</div>
+	</div>
+</div>
+</form>
+{% endblock %}
diff --git a/uffd/session/templates/session/login.html b/uffd/session/templates/session/login.html
index 15c0f8f03e402965ae5ba497e1973f236a47ed37..54ee477d10ccfa997180f5796ea81690c8526d2a 100644
--- a/uffd/session/templates/session/login.html
+++ b/uffd/session/templates/session/login.html
@@ -21,6 +21,12 @@
 		<div class="form-group col-12">
 			<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button>
 		</div>
+		{% if request.values.get('devicelogin') %}
+		<div class="text-center text-muted mb-3">- or -</div>
+		<div class="form-group col-12">
+			<a href="{{ url_for('session.devicelogin_start', ref=ref) }}" class="btn btn-primary btn-block" tabindex="0">Device Login</a>
+		</div>
+		{% endif %}
 		<div class="clearfix col-12">
 			{% if config['SELF_SIGNUP'] %}
 			<a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a>
diff --git a/uffd/session/views.py b/uffd/session/views.py
index 2c57bc6df93c5ffae966ee219759c5a6c3cdd932..0c93ef727c82da4051bfdd5cf198bd213aab3032 100644
--- a/uffd/session/views.py
+++ b/uffd/session/views.py
@@ -4,9 +4,12 @@ import functools
 
 from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session, abort
 
+from uffd.database import db
+from uffd.csrf import csrf_protect
 from uffd.user.models import User
 from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError, LDAPBindError, LDAPPasswordIsMandatoryError
 from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
+from uffd.session.models import DeviceLoginInitiation, DeviceLoginConfirmation
 
 bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
 
@@ -127,3 +130,65 @@ def login_required(group=None):
 			return func(*args, **kwargs)
 		return decorator
 	return wrapper
+
+@bp.route("/login/device/start")
+def devicelogin_start():
+	session['devicelogin_started'] = True
+	return redirect(request.values['ref'])
+
+@bp.route("/login/device")
+def devicelogin():
+	if 'devicelogin_id' not in session or 'devicelogin_secret' not in session:
+		return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+	initiation = DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one_or_none()
+	if not initiation or initiation.expired:
+		flash('Initiation code is no longer valid')
+		return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+	return render_template('session/devicelogin.html', ref=request.values.get('ref'), initiation=initiation)
+
+@bp.route("/login/device", methods=['POST'])
+def devicelogin_submit():
+	if 'devicelogin_id' not in session or 'devicelogin_secret' not in session:
+		return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+	initiation = DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one_or_none()
+	if not initiation or initiation.expired:
+		flash('Initiation code is no longer valid')
+		return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
+	confirmation = DeviceLoginConfirmation.query.filter_by(initiation=initiation, code=request.form['confirmation-code']).one_or_none()
+	if confirmation is None:
+		flash('Invalid confirmation code')
+		return render_template('session/devicelogin.html', ref=request.values.get('ref'), initiation=initiation)
+	session['devicelogin_confirmation'] = confirmation.id
+	return redirect(request.values['ref'])
+
+@bp.route("/device")
+@login_required()
+def deviceauth():
+	if 'initiation-code' not in request.values:
+		return render_template('session/deviceauth.html')
+	initiation = DeviceLoginInitiation.query.filter_by(code=request.values['initiation-code']).one_or_none()
+	if initiation is None or initiation.expired:
+		flash('Invalid initiation code')
+		return redirect(url_for('session.deviceauth'))
+	return render_template('session/deviceauth.html', initiation=initiation)
+
+@bp.route("/device", methods=['POST'])
+@login_required()
+@csrf_protect(blueprint=bp)
+def deviceauth_submit():
+	DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete()
+	initiation = DeviceLoginInitiation.query.filter_by(code=request.form['initiation-code']).one_or_none()
+	if initiation is None or initiation.expired:
+		flash('Invalid initiation code')
+		return redirect(url_for('session.deviceauth'))
+	confirmation = DeviceLoginConfirmation(user=request.user, initiation=initiation)
+	db.session.add(confirmation)
+	db.session.commit()
+	return render_template('session/deviceauth.html', initiation=initiation, confirmation=confirmation)
+
+@bp.route("/device/finish", methods=['GET', 'POST'])
+@login_required()
+def deviceauth_finish():
+	DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete()
+	db.session.commit()
+	return redirect(url_for('index'))
diff --git a/uffd/templates/base.html b/uffd/templates/base.html
index 807af010be4f73c6d3ad2a8e38449c1cbbb4b6a8..4fd7ce50aa7018d6a759f910237031fb3b56296c 100644
--- a/uffd/templates/base.html
+++ b/uffd/templates/base.html
@@ -69,6 +69,12 @@
 				</ul>
 				{% if request.user %}
 				<ul class="navbar-nav ml-auto">
+					<li class="nav-item">
+						<a class="nav-link" href="{{ url_for("session.deviceauth") }}">
+							<span aria-hidden="true" class="fas fa-mobile-alt" title="Authorize Device Login"></span>
+							<span class="d-inline d-md-none">Device Login</span>
+						</a>
+					</li>
 					<li class="nav-item">
 						<a class="nav-link" href="{{ url_for("session.logout") }}">
 							<span aria-hidden="true" class="fa fa-sign-out-alt"></span>