diff --git a/debian/cron.d b/debian/cron.d
index 4b9e06d09c17096332b5af489dedd7e8fc7e3d7f..942624310ed045c10429141a760468ab2c50dae3 100644
--- a/debian/cron.d
+++ b/debian/cron.d
@@ -1,3 +1,4 @@
 # Cronjobs for uffd
 
-@daily	uffd	[ -f /usr/bin/uffd-admin ] && flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null
+@daily	uffd	flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null
+@daily	uffd	flock -n /var/run/uffd/cron.cleanup.lock /usr/bin/uffd-admin cleanup > /dev/null
diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py
index cf7501fa99e752d3d549f528191a501a997eb8b4..f0bf393c10ceb169a30984c2dc220d29a1868671 100644
--- a/tests/test_ratelimit.py
+++ b/tests/test_ratelimit.py
@@ -48,19 +48,3 @@ class TestRatelimit(UffdTestCase):
 		self.assertIsInstance(format_delay(120), str)
 		self.assertIsInstance(format_delay(3600), str)
 		self.assertIsInstance(format_delay(4000), str)
-
-	def test_cleanup(self):
-		ratelimit = Ratelimit('test', 1, 1)
-		ratelimit.log('')
-		ratelimit.log('1')
-		ratelimit.log('2')
-		ratelimit.log('3')
-		ratelimit.log('4')
-		time.sleep(1)
-		ratelimit.log('5')
-		self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 6)
-		ratelimit.cleanup()
-		self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 1)
-		time.sleep(1)
-		ratelimit.cleanup()
-		self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 0)
diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py
index cd9988be88d3e582d500cdd7fa24454674decef1..c6724ef4016919fb3d03680a4fed5514d699220e 100644
--- a/tests/test_selfservice.py
+++ b/tests/test_selfservice.py
@@ -200,7 +200,8 @@ class TestSelfservice(UffdTestCase):
 		_user = request.user
 		self.assertEqual(_user.mail, old_mail)
 		tokens = MailToken.query.filter(MailToken.user == _user).all()
-		self.assertEqual(len(tokens), 0)
+		self.assertEqual(len(tokens), 1)
+		self.assertTrue(tokens[0].expired)
 
 	def test_forgot_password(self):
 		user = self.get_user()
@@ -251,6 +252,7 @@ class TestSelfservice(UffdTestCase):
 		token = PasswordToken(user=user)
 		db.session.add(token)
 		db.session.commit()
+		self.assertFalse(token.expired)
 		r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token=token.token), follow_redirects=True)
 		dump('token_password', r)
 		self.assertEqual(r.status_code, 200)
@@ -265,12 +267,12 @@ class TestSelfservice(UffdTestCase):
 		r = self.client.get(path=url_for('selfservice.token_password', token_id=1, token='A'*128), follow_redirects=True)
 		dump('token_password_emptydb', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		r = self.client.post(path=url_for('selfservice.token_password', token_id=1, token='A'*128),
 			data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True)
 		dump('token_password_emptydb_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		self.assertTrue(self.get_user().password.verify('userpassword'))
 
 	def test_token_password_invalid(self):
@@ -281,12 +283,12 @@ class TestSelfservice(UffdTestCase):
 		r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token='A'*128), follow_redirects=True)
 		dump('token_password_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		r = self.client.post(path=url_for('selfservice.token_password', token_id=token.id, token='A'*128),
 			data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True)
 		dump('token_password_invalid_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		self.assertTrue(self.get_user().password.verify('userpassword'))
 
 	def test_token_password_expired(self):
@@ -294,15 +296,16 @@ class TestSelfservice(UffdTestCase):
 		token = PasswordToken(user=user, created=(datetime.datetime.now() - datetime.timedelta(days=10)))
 		db.session.add(token)
 		db.session.commit()
+		self.assertTrue(token.expired)
 		r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token=token.token), follow_redirects=True)
 		dump('token_password_invalid_expired', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		r = self.client.post(path=url_for('selfservice.token_password', token_id=token.id, token=token.token),
 			data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True)
 		dump('token_password_invalid_expired_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'Token expired, please try again', r.data)
+		self.assertIn(b'Link invalid or expired', r.data)
 		self.assertTrue(self.get_user().password.verify('userpassword'))
 
 	def test_token_password_different_passwords(self):
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..8f146d5ac8aa3e8c95283657478bc71396328c5b
--- /dev/null
+++ b/tests/test_tasks.py
@@ -0,0 +1,36 @@
+import unittest
+
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+
+from uffd.tasks import CleanupTask
+
+class TestCleanupTask(unittest.TestCase):
+	def test(self):
+		app = Flask(__name__)
+		app.testing = True
+		app.debug = True
+		app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
+		db = SQLAlchemy(app)
+		cleanup_task = CleanupTask(app, db)
+
+		@cleanup_task.delete_by_attribute('delete_me')
+		class TestModel(db.Model):
+			id = db.Column(db.Integer(), primary_key=True, autoincrement=True)
+			delete_me = db.Column(db.Boolean(), default=False, nullable=False)
+
+		with app.test_request_context():
+			db.create_all()
+			db.session.add(TestModel(delete_me=True))
+			db.session.add(TestModel(delete_me=True))
+			db.session.add(TestModel(delete_me=True))
+			db.session.add(TestModel(delete_me=False))
+			db.session.add(TestModel(delete_me=False))
+			db.session.commit()
+			db.session.expire_all()
+			self.assertEqual(TestModel.query.count(), 5)
+
+		app.test_cli_runner().invoke(args=['cleanup'])
+
+		with app.test_request_context():
+			self.assertEqual(TestModel.query.count(), 2)
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 611f107c378286358dbd8506a93cc9d18e0da7d1..03ba6d42569c7f5f311b35889d8197472891772a 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -14,6 +14,7 @@ from werkzeug.exceptions import InternalServerError, Forbidden
 from flask_migrate import Migrate
 
 from uffd.database import db, SQLAlchemyJSON, customize_db_engine
+from uffd.tasks import cleanup_task
 from uffd.template_helper import register_template_helper
 from uffd.navbar import setup_navbar
 from uffd.secure_redirect import secure_local_redirect
@@ -82,6 +83,8 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta
 	with app.app_context():
 		customize_db_engine(db.engine)
 
+	cleanup_task.init_app(app, db)
+
 	for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, services, rolemod, api, signup, invite]:
 		for bp in module.bp:
 			app.register_blueprint(bp)
diff --git a/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py b/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py
new file mode 100644
index 0000000000000000000000000000000000000000..70db4f4618ba302f95727f5d052bf172a6d9ab51
--- /dev/null
+++ b/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py
@@ -0,0 +1,45 @@
+"""add expires attribute to ratelimit_event
+
+Revision ID: 09d2edcaf0cc
+Revises: af07cea65391
+Create Date: 2022-02-15 14:16:19.318253
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '09d2edcaf0cc'
+down_revision = 'af07cea65391'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	ratelimit_event = sa.Table('ratelimit_event', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('timestamp', sa.DateTime(), nullable=True),
+		sa.Column('name', sa.String(length=128), nullable=True),
+		sa.Column('key', sa.String(length=128), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_ratelimit_event'))
+	)
+	op.execute(ratelimit_event.delete())
+	with op.batch_alter_table('ratelimit_event', copy_from=ratelimit_event) as batch_op:
+		batch_op.add_column(sa.Column('expires', sa.DateTime(), nullable=False))
+		batch_op.alter_column('name', existing_type=sa.VARCHAR(length=128), nullable=False)
+		batch_op.alter_column('timestamp', existing_type=sa.DATETIME(), nullable=False)
+
+def downgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	ratelimit_event = sa.Table('ratelimit_event', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('timestamp', sa.DateTime(), nullable=False),
+		sa.Column('expires', sa.DateTime(), nullable=False),
+		sa.Column('name', sa.String(length=128), nullable=False),
+		sa.Column('key', sa.String(length=128), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_ratelimit_event'))
+	)
+	op.execute(ratelimit_event.delete())
+	with op.batch_alter_table('ratelimit_event', schema=None) as batch_op:
+		batch_op.alter_column('timestamp', existing_type=sa.DATETIME(), nullable=True)
+		batch_op.alter_column('name', existing_type=sa.VARCHAR(length=128), nullable=True)
+		batch_op.drop_column('expires')
diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py
index 211f2c22ee26adde4d35e470d5166af699ff7f14..374707514f18c1b5780bab44d53bb0e5420d9992 100644
--- a/uffd/oauth2/models.py
+++ b/uffd/oauth2/models.py
@@ -1,9 +1,13 @@
+import datetime
+
 from flask import current_app
 from flask_babel import get_locale, gettext as _
 from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
+from uffd.tasks import cleanup_task
 from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
 
 class OAuth2Client:
@@ -39,6 +43,7 @@ class OAuth2Client:
 	def access_allowed(self, user):
 		return user.has_permission(self.required_group)
 
+@cleanup_task.delete_by_attribute('expired')
 class OAuth2Grant(db.Model):
 	__tablename__ = 'oauth2grant'
 	id = Column(Integer, primary_key=True, autoincrement=True)
@@ -58,7 +63,7 @@ class OAuth2Grant(db.Model):
 
 	code = Column(String(255), index=True, nullable=False)
 	redirect_uri = Column(String(255), nullable=False)
-	expires = Column(DateTime, nullable=False)
+	expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=100))
 
 	_scopes = Column(Text, nullable=False, default='')
 	@property
@@ -72,6 +77,13 @@ class OAuth2Grant(db.Model):
 		db.session.commit()
 		return self
 
+	@hybrid_property
+	def expired(self):
+		if self.expires is None:
+			return False
+		return self.expires < datetime.datetime.utcnow()
+
+@cleanup_task.delete_by_attribute('expired')
 class OAuth2Token(db.Model):
 	__tablename__ = 'oauth2token'
 	id = Column(Integer, primary_key=True, autoincrement=True)
@@ -107,6 +119,14 @@ class OAuth2Token(db.Model):
 		db.session.commit()
 		return self
 
+	@hybrid_property
+	def expired(self):
+		return self.expires < datetime.datetime.utcnow()
+
+	def set_expires_in_seconds(self, seconds):
+		self.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
+	expires_in_seconds = property(fset=set_expires_in_seconds)
+
 class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
 	__mapper_args__ = {
 		'polymorphic_identity': DeviceLoginType.OAUTH2
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index 4477e0edae16803f2790ea7305d8dec935bb7439..db5b69783f6dbc6fd4351d575ff0dfff7b5f2197 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -1,4 +1,3 @@
-import datetime
 import functools
 import secrets
 import urllib.parse
@@ -66,9 +65,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		return set(scopes).issubset({'profile'})
 
 	def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
-		expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
 		grant = OAuth2Grant(user=oauthreq.user, client_id=client_id, code=code['code'],
-		                    redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
+		                    redirect_uri=oauthreq.redirect_uri, _scopes=' '.join(oauthreq.scopes))
 		db.session.add(grant)
 		db.session.commit()
 		# Oauthlib does not really provide a way to customize grant code generation.
@@ -86,7 +84,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 			return False
 		if not secrets.compare_digest(oauthreq.grant.code, grant_code):
 			return False
-		if datetime.datetime.utcnow() > oauthreq.grant.expires:
+		if oauthreq.grant.expired:
 			return False
 		oauthreq.user = oauthreq.grant.user
 		oauthreq.scopes = oauthreq.grant.scopes
@@ -98,15 +96,13 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 
 	def save_bearer_token(self, token_data, oauthreq, *args, **kwargs):
 		OAuth2Token.query.filter_by(client_id=oauthreq.client.client_id, user=oauthreq.user).delete()
-		expires_in = token_data.get('expires_in')
-		expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)
 		tok = OAuth2Token(
 			user=oauthreq.user,
 			client_id=oauthreq.client.client_id,
 			token_type=token_data['token_type'],
 			access_token=token_data['access_token'],
 			refresh_token=token_data['refresh_token'],
-			expires=expires,
+			expires_in_seconds=token_data['expires_in'],
 			_scopes=' '.join(oauthreq.scopes)
 		)
 		db.session.add(tok)
@@ -133,7 +129,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		tok = OAuth2Token.query.get(tok_id)
 		if not tok or not secrets.compare_digest(tok.access_token, tok_secret):
 			return False
-		if datetime.datetime.utcnow() > tok.expires:
+		if tok.expired:
 			oauthreq.error_message = 'Token expired'
 			return False
 		if not set(scopes).issubset(tok.scopes):
diff --git a/uffd/ratelimit.py b/uffd/ratelimit.py
index 46c97e424fd6da84a99c39d3bad269719cc422a5..5604299d983a0e076e107dc2f9e1f46e5f14cfb3 100644
--- a/uffd/ratelimit.py
+++ b/uffd/ratelimit.py
@@ -5,16 +5,24 @@ import math
 from flask import request
 from flask_babel import gettext as _
 from sqlalchemy import Column, Integer, String, DateTime
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
+from uffd.tasks import cleanup_task
 
+@cleanup_task.delete_by_attribute('expired')
 class RatelimitEvent(db.Model):
 	__tablename__ = 'ratelimit_event'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
-	timestamp = Column(DateTime(), default=datetime.datetime.now)
-	name = Column(String(128))
+	timestamp = Column(DateTime(), default=datetime.datetime.utcnow, nullable=False)
+	expires = Column(DateTime(), nullable=False)
+	name = Column(String(128), nullable=False)
 	key = Column(String(128))
 
+	@hybrid_property
+	def expired(self):
+		return self.expires < datetime.datetime.utcnow()
+
 class Ratelimit:
 	def __init__(self, name, interval, limit):
 		self.name = name
@@ -22,25 +30,23 @@ class Ratelimit:
 		self.limit = limit
 		self.base = interval**(1/limit)
 
-	def cleanup(self):
-		limit = datetime.datetime.now() - datetime.timedelta(seconds=self.interval)
-		RatelimitEvent.query.filter(RatelimitEvent.name == self.name, RatelimitEvent.timestamp <= limit).delete()
-		db.session.commit()
-
 	def log(self, key=None):
-		db.session.add(RatelimitEvent(name=self.name, key=key))
+		db.session.add(RatelimitEvent(name=self.name, key=key, expires=datetime.datetime.utcnow() + datetime.timedelta(seconds=self.interval)))
 		db.session.commit()
 
 	def get_delay(self, key=None):
-		self.cleanup()
-		events = RatelimitEvent.query.filter(RatelimitEvent.name == self.name, RatelimitEvent.key == key).all()
+		events = RatelimitEvent.query\
+				.filter(db.not_(RatelimitEvent.expired))\
+				.filter_by(name=self.name, key=key)\
+				.order_by(RatelimitEvent.timestamp)\
+				.all()
 		if not events:
 			return 0
 		delay = math.ceil(self.base**len(events))
 		if delay < 5:
 			delay = 0
-		delay = min(delay, 365*24*60*60) # prevent overflow of datetime objetcs
-		remaining = events[0].timestamp + datetime.timedelta(seconds=delay) - datetime.datetime.now()
+		delay = min(delay, 365*24*60*60) # prevent overflow of datetime objects
+		remaining = events[0].timestamp + datetime.timedelta(seconds=delay) - datetime.datetime.utcnow()
 		return max(0, math.ceil(remaining.total_seconds()))
 
 def get_addrkey(addr=None):
diff --git a/uffd/selfservice/models.py b/uffd/selfservice/models.py
index b0103f70379a21639715267add4085def91bf057..4ac327bd597230040c78cc3c020f990853fa7819 100644
--- a/uffd/selfservice/models.py
+++ b/uffd/selfservice/models.py
@@ -2,10 +2,13 @@ import datetime
 
 from sqlalchemy import Column, String, DateTime, Integer, ForeignKey
 from sqlalchemy.orm import relationship
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
+from uffd.tasks import cleanup_task
 from uffd.utils import token_urlfriendly
 
+@cleanup_task.delete_by_attribute('expired')
 class PasswordToken(db.Model):
 	__tablename__ = 'passwordToken'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
@@ -14,6 +17,13 @@ class PasswordToken(db.Model):
 	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
 	user = relationship('User')
 
+	@hybrid_property
+	def expired(self):
+		if self.created is None:
+			return False
+		return self.created < datetime.datetime.now() - datetime.timedelta(days=2)
+
+@cleanup_task.delete_by_attribute('expired')
 class MailToken(db.Model):
 	__tablename__ = 'mailToken'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
@@ -22,3 +32,9 @@ class MailToken(db.Model):
 	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
 	user = relationship('User')
 	newmail = Column(String(255))
+
+	@hybrid_property
+	def expired(self):
+		if self.created is None:
+			return False
+		return self.created < datetime.datetime.now() - datetime.timedelta(days=2)
diff --git a/uffd/selfservice/views.py b/uffd/selfservice/views.py
index 8760499ac099aed9dc070c630b8710d658c621d2..3103c118acaf64ac723b2e9234e08d810bc6a708 100644
--- a/uffd/selfservice/views.py
+++ b/uffd/selfservice/views.py
@@ -1,4 +1,3 @@
-import datetime
 import secrets
 
 from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
@@ -83,11 +82,8 @@ def forgot_password():
 def token_password(token_id, token):
 	dbtoken = PasswordToken.query.get(token_id)
 	if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \
-			dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)):
-		flash(_('Token expired, please try again.'))
-		if dbtoken:
-			db.session.delete(dbtoken)
-			db.session.commit()
+			dbtoken.expired:
+		flash(_('Link invalid or expired'))
 		return redirect(url_for('session.login'))
 	if request.method == 'GET':
 		return render_template('selfservice/set_password.html', token=dbtoken)
@@ -103,8 +99,8 @@ def token_password(token_id, token):
 		flash(_('Password ist not valid, please try again.'))
 		return render_template('selfservice/set_password.html', token=dbtoken)
 	db.session.delete(dbtoken)
-	flash(_('New password set'))
 	db.session.commit()
+	flash(_('New password set'))
 	return redirect(url_for('session.login'))
 
 @bp.route("/token/mail_verification/<int:token_id>/<token>")
@@ -112,18 +108,15 @@ def token_password(token_id, token):
 def token_mail(token_id, token):
 	dbtoken = MailToken.query.get(token_id)
 	if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \
-			dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)):
-		flash(_('Token expired, please try again.'))
-		if dbtoken:
-			db.session.delete(dbtoken)
-			db.session.commit()
+			dbtoken.expired:
+		flash(_('Link invalid or expired'))
 		return redirect(url_for('selfservice.index'))
 	if dbtoken.user != request.user:
 		abort(403, description=_('This link was generated for another user. Login as the correct user to continue.'))
 	dbtoken.user.set_mail(dbtoken.newmail)
-	flash(_('New mail set'))
 	db.session.delete(dbtoken)
 	db.session.commit()
+	flash(_('New mail set'))
 	return redirect(url_for('selfservice.index'))
 
 @bp.route("/leaverole/<int:roleid>", methods=(['POST']))
@@ -138,8 +131,7 @@ def leave_role(roleid):
 	return redirect(url_for('selfservice.index'))
 
 def send_mail_verification(user, newmail):
-	MailToken.query.filter(db.or_(MailToken.created < (datetime.datetime.now() - datetime.timedelta(days=2)),
-	                              MailToken.user == user)).delete()
+	MailToken.query.filter(MailToken.user == user).delete()
 	token = MailToken(user=user, newmail=newmail)
 	db.session.add(token)
 	db.session.commit()
@@ -148,8 +140,7 @@ def send_mail_verification(user, newmail):
 		flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=newmail))
 
 def send_passwordreset(user, new=False):
-	PasswordToken.query.filter(db.or_(PasswordToken.created < (datetime.datetime.now() - datetime.timedelta(days=2)),
-	                                  PasswordToken.user == user)).delete()
+	PasswordToken.query.filter(PasswordToken.user == user).delete()
 	token = PasswordToken(user=user)
 	db.session.add(token)
 	db.session.commit()
diff --git a/uffd/session/models.py b/uffd/session/models.py
index a64dd5cdb99a06aad06c5ebfbe32bbaad867534d..4b2099e8a5c88780778146b686ab1b40f068026e 100644
--- a/uffd/session/models.py
+++ b/uffd/session/models.py
@@ -7,6 +7,7 @@ from sqlalchemy.orm import relationship
 from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
+from uffd.tasks import cleanup_task
 from uffd.utils import token_typeable
 
 # Device login provides a convenient and secure way to log into SSO-enabled
@@ -55,6 +56,7 @@ from uffd.utils import token_typeable
 class DeviceLoginType(enum.Enum):
 	OAUTH2 = 0
 
+@cleanup_task.delete_by_attribute('expired')
 class DeviceLoginInitiation(db.Model):
 	'''Abstract initiation code class
 
@@ -92,6 +94,8 @@ class DeviceLoginInitiation(db.Model):
 
 	@hybrid_property
 	def expired(self):
+		if self.created is None:
+			return False
 		return self.created < datetime.datetime.now() - datetime.timedelta(minutes=30)
 
 	@property
diff --git a/uffd/signup/models.py b/uffd/signup/models.py
index af19bd5d537a2f5de83d85a13dbf4394b7a66d0a..ab9920ae297fb7cf915f8d9baa00e9905cfcdf92 100644
--- a/uffd/signup/models.py
+++ b/uffd/signup/models.py
@@ -2,12 +2,15 @@ import datetime
 
 from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship, backref
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
+from uffd.tasks import cleanup_task
 from uffd.user.models import User
 from uffd.utils import token_urlfriendly
 from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash
 
+@cleanup_task.delete_by_attribute('expired_and_not_completed')
 class Signup(db.Model):
 	'''Model that represents a self-signup request
 
@@ -22,8 +25,8 @@ class Signup(db.Model):
 	address does not allow a third party to complete the signup procedure and
 	set a new password with the (also mail-based) password reset functionality.
 
-	As long as they are not completed, signup requests have no effect each other
-	or different parts of the application.'''
+	As long as they are not completed, signup requests have no effect on each
+	other or different parts of the application.'''
 	__tablename__ = 'signup'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
 	token = Column(String(128), default=token_urlfriendly, nullable=False)
@@ -48,13 +51,20 @@ class Signup(db.Model):
 		self.password = value
 		return True
 
-	@property
+	@hybrid_property
 	def expired(self):
-		return self.created is not None and datetime.datetime.now() >= self.created + datetime.timedelta(hours=48)
+		if self.created is None:
+			return False
+		return self.created < datetime.datetime.now() - datetime.timedelta(hours=48)
 
-	@property
+	@hybrid_property
 	def completed(self):
-		return self.user is not None
+		# pylint: disable=singleton-comparison
+		return self.user_id != None
+
+	@hybrid_property
+	def expired_and_not_completed(self):
+		return db.and_(self.expired, db.not_(self.completed))
 
 	def validate(self): # pylint: disable=too-many-return-statements
 		'''Return whether the signup request is valid and Signup.finish is likely to succeed
diff --git a/uffd/tasks.py b/uffd/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ae6d380f9d221b54d8c57ce2ade602c4e065eb8
--- /dev/null
+++ b/uffd/tasks.py
@@ -0,0 +1,29 @@
+class CleanupTask:
+	def __init__(self, *init_args):
+		self.handlers = []
+		if init_args:
+			self.init_app(*init_args)
+
+	def init_app(self, app, db):
+		@app.cli.command('cleanup', help='Cleanup expired data')
+		def cleanup(): #pylint: disable=unused-variable
+			self.run()
+			db.session.commit()
+
+	def handler(self, func):
+		self.handlers.append(func)
+		return func
+
+	def delete_by_attribute(self, attribute):
+		def decorator(cls):
+			@self.handler
+			def handler():
+				cls.query.filter(getattr(cls, attribute)).delete()
+			return cls
+		return decorator
+
+	def run(self):
+		for handler in self.handlers:
+			handler()
+
+cleanup_task = CleanupTask()
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 7178b9b68894ad12809ce2d9fae534e339fbd7e1..fae15547dea316a68d4e4a766cc5cbe185c27655 100644
Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ
diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 217cba4fc9b5ceef6127d5492f3c6b232f6c2d42..aac46a197d92b30b2b7f1834a1f40315ff96e8a4 100644
--- a/uffd/translations/de/LC_MESSAGES/messages.po
+++ b/uffd/translations/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2022-02-03 16:51+0100\n"
+"POT-Creation-Date: 2022-02-15 23:23+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -18,31 +18,31 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Generated-By: Babel 2.9.1\n"
 
-#: uffd/ratelimit.py:70
+#: uffd/ratelimit.py:76
 msgid "a few seconds"
 msgstr "ein paar Sekunden"
 
-#: uffd/ratelimit.py:72
+#: uffd/ratelimit.py:78
 msgid "30 seconds"
 msgstr "30 Sekunden"
 
-#: uffd/ratelimit.py:74
+#: uffd/ratelimit.py:80
 msgid "one minute"
 msgstr "eine Minute"
 
-#: uffd/ratelimit.py:76
+#: uffd/ratelimit.py:82
 #, python-format
 msgid "%(minutes)d minutes"
 msgstr "%(minutes)d Minuten"
 
-#: uffd/ratelimit.py:78
+#: uffd/ratelimit.py:84
 msgid "one hour"
 msgstr "eine Stunde"
 
-#: uffd/ratelimit.py:79
+#: uffd/ratelimit.py:85
 #, python-format
 msgid "%(hours)d hours"
-msgstr "%(hours)d Stunden\""
+msgstr "%(hours)d Stunden"
 
 #: uffd/invite/views.py:46
 msgid "Invites"
@@ -74,7 +74,7 @@ msgstr "Rollen erfolgreich geändert"
 msgid "Invite link does not allow signup"
 msgstr "Einladungslink erlaubt keine Account-Registrierung"
 
-#: uffd/invite/views.py:175 uffd/selfservice/views.py:50
+#: uffd/invite/views.py:175 uffd/selfservice/views.py:49
 #: uffd/signup/views.py:49
 msgid "Passwords do not match"
 msgstr "Die Passwörter stimmen nicht überein"
@@ -86,12 +86,12 @@ msgstr ""
 "Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte "
 "%(delay)s."
 
-#: uffd/invite/views.py:182 uffd/signup/views.py:56 uffd/signup/views.py:92
+#: uffd/invite/views.py:182 uffd/signup/views.py:56 uffd/signup/views.py:96
 #, python-format
 msgid "Too many requests! Please wait %(delay)s."
 msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
 
-#: uffd/invite/views.py:195 uffd/signup/views.py:68
+#: uffd/invite/views.py:195 uffd/signup/views.py:72
 msgid "Cound not send mail"
 msgstr "Mailversand fehlgeschlagen"
 
@@ -721,11 +721,11 @@ msgstr "Sekunden"
 msgid "Verify and complete setup"
 msgstr "Verifiziere und beende das Setup"
 
-#: uffd/oauth2/models.py:29
+#: uffd/oauth2/models.py:33
 msgid "You need to login to access this service"
 msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
 
-#: uffd/oauth2/views.py:173 uffd/selfservice/views.py:72
+#: uffd/oauth2/views.py:169 uffd/selfservice/views.py:71
 #: uffd/session/views.py:73
 #, python-format
 msgid ""
@@ -735,15 +735,15 @@ msgstr ""
 "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
 "Netzwerk empfangen! Bitte warte mindestens %(delay)s."
 
-#: uffd/oauth2/views.py:181
+#: uffd/oauth2/views.py:177
 msgid "Device login is currently not available. Try again later!"
 msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
 
-#: uffd/oauth2/views.py:194
+#: uffd/oauth2/views.py:190
 msgid "Device login failed"
 msgstr "Gerätelogin fehlgeschlagen"
 
-#: uffd/oauth2/views.py:207
+#: uffd/oauth2/views.py:203
 #, python-format
 msgid ""
 "You don't have the permission to access the service "
@@ -929,31 +929,31 @@ msgstr "Mitglieder:"
 msgid "Remove"
 msgstr "Entfernen"
 
-#: uffd/selfservice/views.py:25
+#: uffd/selfservice/views.py:24
 msgid "Selfservice"
 msgstr "Selfservice"
 
-#: uffd/selfservice/views.py:36
+#: uffd/selfservice/views.py:35
 msgid "Display name changed."
 msgstr "Anzeigename geändert."
 
-#: uffd/selfservice/views.py:38
+#: uffd/selfservice/views.py:37
 msgid "Display name is not valid."
 msgstr "Anzeigename ist nicht valide."
 
-#: uffd/selfservice/views.py:41
+#: uffd/selfservice/views.py:40
 msgid "We sent you an email, please verify your mail address."
 msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse."
 
-#: uffd/selfservice/views.py:53
+#: uffd/selfservice/views.py:52
 msgid "Password changed"
 msgstr "Passwort geändert"
 
-#: uffd/selfservice/views.py:55
+#: uffd/selfservice/views.py:54
 msgid "Invalid password"
 msgstr "Passwort ungültig"
 
-#: uffd/selfservice/views.py:70
+#: uffd/selfservice/views.py:69
 #, python-format
 msgid ""
 "We received too many password reset requests for this user! Please wait "
@@ -962,7 +962,7 @@ msgstr ""
 "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! "
 "Bitte warte mindestens %(delay)s."
 
-#: uffd/selfservice/views.py:76
+#: uffd/selfservice/views.py:75
 msgid ""
 "We sent a mail to this user's mail address if you entered the correct "
 "mail and login name combination"
@@ -970,27 +970,27 @@ msgstr ""
 "Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an "
 "die Adresse gesendet."
 
-#: uffd/selfservice/views.py:87 uffd/selfservice/views.py:116
-msgid "Token expired, please try again."
-msgstr "Link abgelaufen, bitte versuche es erneut."
+#: uffd/selfservice/views.py:86 uffd/selfservice/views.py:112
+msgid "Link invalid or expired"
+msgstr "Link ist ungültig oder abgelaufen"
 
-#: uffd/selfservice/views.py:95
+#: uffd/selfservice/views.py:91
 msgid "You need to set a password, please try again."
 msgstr "Password fehlt, bitte versuche es erneut."
 
-#: uffd/selfservice/views.py:98
+#: uffd/selfservice/views.py:94
 msgid "Passwords do not match, please try again."
 msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut"
 
-#: uffd/selfservice/views.py:103
+#: uffd/selfservice/views.py:99
 msgid "Password ist not valid, please try again."
 msgstr "Ungültiges Passwort, bitte versuche es erneut"
 
-#: uffd/selfservice/views.py:106
+#: uffd/selfservice/views.py:103
 msgid "New password set"
 msgstr "Passwort geändert"
 
-#: uffd/selfservice/views.py:122
+#: uffd/selfservice/views.py:115
 msgid ""
 "This link was generated for another user. Login as the correct user to "
 "continue."
@@ -998,16 +998,16 @@ msgstr ""
 "Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem "
 "richtigen Account an um Fortzufahren."
 
-#: uffd/selfservice/views.py:124
+#: uffd/selfservice/views.py:119
 msgid "New mail set"
 msgstr "E-Mail-Adresse geändert"
 
-#: uffd/selfservice/views.py:137
+#: uffd/selfservice/views.py:130
 #, python-format
 msgid "You left role %(role_name)s"
 msgstr "Rolle %(role_name)s verlassen"
 
-#: uffd/selfservice/views.py:148 uffd/selfservice/views.py:165
+#: uffd/selfservice/views.py:140 uffd/selfservice/views.py:156
 #, python-format
 msgid "Mail to \"%(mail_address)s\" could not be sent!"
 msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
@@ -1209,23 +1209,23 @@ msgstr ""
 msgid "Login name or password is wrong"
 msgstr "Der Anmeldename oder das Passwort ist falsch"
 
-#: uffd/session/views.py:82
+#: uffd/session/views.py:85
 msgid "You do not have access to this service"
 msgstr "Du hast keinen Zugriff auf diesen Service"
 
-#: uffd/session/views.py:94 uffd/session/views.py:105
+#: uffd/session/views.py:97 uffd/session/views.py:108
 msgid "You need to login first"
 msgstr "Du musst dich erst anmelden"
 
-#: uffd/session/views.py:126 uffd/session/views.py:136
+#: uffd/session/views.py:129 uffd/session/views.py:139
 msgid "Initiation code is no longer valid"
 msgstr "Startcode ist nicht mehr gültig"
 
-#: uffd/session/views.py:140
+#: uffd/session/views.py:143
 msgid "Invalid confirmation code"
 msgstr "Ungültiger Bestätigungscode"
 
-#: uffd/session/views.py:152 uffd/session/views.py:163
+#: uffd/session/views.py:155 uffd/session/views.py:166
 msgid "Invalid initiation code"
 msgstr "Ungültiger Startcode"
 
@@ -1335,20 +1335,20 @@ msgstr "Passwort vergessen?"
 msgid "Singup not enabled"
 msgstr "Account-Registrierung ist deaktiviert"
 
-#: uffd/signup/views.py:77 uffd/signup/views.py:85
+#: uffd/signup/views.py:81 uffd/signup/views.py:89
 msgid "Invalid signup link"
 msgstr "Ungültiger Account-Registrierungs-Link"
 
-#: uffd/signup/views.py:90
+#: uffd/signup/views.py:94
 #, python-format
 msgid "Too many failed attempts! Please wait %(delay)s."
 msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s."
 
-#: uffd/signup/views.py:96
+#: uffd/signup/views.py:100
 msgid "Wrong password"
 msgstr "Falsches Passwort"
 
-#: uffd/signup/views.py:102
+#: uffd/signup/views.py:106
 msgid "Your account was successfully created"
 msgstr "Account erfolgreich erstellt"
 
@@ -1445,7 +1445,7 @@ msgstr "Ändern"
 msgid "About uffd"
 msgstr "Über uffd"
 
-#: uffd/user/models.py:77
+#: uffd/user/models.py:48
 #, python-format
 msgid ""
 "At least %(minlen)d and at most %(maxlen)d characters. Only letters, "