diff --git a/.gitmodules b/.gitmodules
index 51e8aed7051feb4f6e990a67fdbd2aa5b24e7957..f71540171466594ed28073c553f91825530fbf00 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
 [submodule "deps/ldapalchemy"]
 	path = deps/ldapalchemy
-	url = ../ldapalchemy.git
+	url = https://git.cccv.de/infra/uffd/ldapalchemy.git
diff --git a/migrations/versions/2a6b1fb82ce6_added_missing_oauth2grant_code_index.py b/migrations/versions/2a6b1fb82ce6_added_missing_oauth2grant_code_index.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d24b8aa6800054d6bf1a79b31d0b639a0b8e54c
--- /dev/null
+++ b/migrations/versions/2a6b1fb82ce6_added_missing_oauth2grant_code_index.py
@@ -0,0 +1,23 @@
+"""added missing oauth2grant.code index
+
+Revision ID: 2a6b1fb82ce6
+Revises: cbca20cf64d9
+Create Date: 2021-04-13 23:03:46.280189
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '2a6b1fb82ce6'
+down_revision = 'cbca20cf64d9'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	with op.batch_alter_table('oauth2grant', schema=None) as batch_op:
+		batch_op.create_index(batch_op.f('ix_oauth2grant_code'), ['code'], unique=False)
+
+def downgrade():
+	with op.batch_alter_table('oauth2grant', schema=None) as batch_op:
+		batch_op.drop_index(batch_op.f('ix_oauth2grant_code'))
diff --git a/migrations/versions/cbca20cf64d9_constraint_name_fixes.py b/migrations/versions/cbca20cf64d9_constraint_name_fixes.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc237505b4e7e2fa71c6a488fb94de7bd56ca33c
--- /dev/null
+++ b/migrations/versions/cbca20cf64d9_constraint_name_fixes.py
@@ -0,0 +1,186 @@
+"""constraint name fixes
+
+Revision ID: cbca20cf64d9
+Revises: 
+Create Date: 2021-04-13 18:10:58.210232
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'cbca20cf64d9'
+down_revision = '5a07d4a63b64'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	# This migration recreates all tables with identical columns and constraints.
+	# The only difference is that all contraints are named according to the newly
+	# defined naming conventions. This enables changing constraints in future
+	# migrations.
+	meta = sa.MetaData(bind=op.get_bind())
+	# We call batch_alter_table without any operations to have it recreate all
+	# tables with the column/constraint definitions from "table" and populate it
+	# with the data from the original table.
+	table = sa.Table('invite', meta,
+		sa.Column('token', sa.String(length=128), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=False),
+		sa.Column('valid_until', sa.DateTime(), nullable=False),
+		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.PrimaryKeyConstraint('token', name=op.f('pk_invite'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('mailToken', meta,
+		sa.Column('token', sa.String(length=128), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=True),
+		sa.Column('loginname', sa.String(length=32), nullable=True),
+		sa.Column('newmail', sa.String(length=255), nullable=True),
+		sa.PrimaryKeyConstraint('token', name=op.f('pk_mailToken'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('mfa_method', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='mfatype'), nullable=True),
+		sa.Column('created', sa.DateTime(), nullable=True),
+		sa.Column('name', sa.String(length=128), nullable=True),
+		sa.Column('dn', sa.String(length=128), nullable=True),
+		sa.Column('recovery_salt', sa.String(length=64), nullable=True),
+		sa.Column('recovery_hash', sa.String(length=256), nullable=True),
+		sa.Column('totp_key', sa.String(length=64), nullable=True),
+		sa.Column('webauthn_cred', sa.Text(), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_mfa_method'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('oauth2grant', meta,
+		sa.Column('id', sa.Integer(), nullable=False),
+		sa.Column('user_dn', sa.String(length=128), nullable=True),
+		sa.Column('client_id', sa.String(length=40), nullable=True),
+		sa.Column('code', sa.String(length=255), nullable=False),
+		sa.Column('redirect_uri', sa.String(length=255), nullable=True),
+		sa.Column('expires', sa.DateTime(), nullable=True),
+		sa.Column('_scopes', sa.Text(), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('oauth2token', meta,
+		sa.Column('id', sa.Integer(), nullable=False),
+		sa.Column('user_dn', sa.String(length=128), nullable=True),
+		sa.Column('client_id', sa.String(length=40), nullable=True),
+		sa.Column('token_type', sa.String(length=40), nullable=True),
+		sa.Column('access_token', sa.String(length=255), nullable=True),
+		sa.Column('refresh_token', sa.String(length=255), nullable=True),
+		sa.Column('expires', sa.DateTime(), nullable=True),
+		sa.Column('_scopes', sa.Text(), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')),
+		sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')),
+		sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('passwordToken', meta,
+		sa.Column('token', sa.String(length=128), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=True),
+		sa.Column('loginname', sa.String(length=32), nullable=True),
+		sa.PrimaryKeyConstraint('token', name=op.f('pk_passwordToken'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = 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'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('role', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=32), nullable=True),
+		sa.Column('description', sa.Text(), nullable=True),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_role')),
+		sa.UniqueConstraint('name', name=op.f('uq_role_name'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('signup', meta,
+		sa.Column('token', sa.String(length=128), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=False),
+		sa.Column('loginname', sa.Text(), nullable=True),
+		sa.Column('displayname', sa.Text(), nullable=True),
+		sa.Column('mail', sa.Text(), nullable=True),
+		sa.Column('pwhash', sa.Text(), nullable=True),
+		sa.Column('user_dn', sa.String(length=128), nullable=True),
+		sa.Column('type', sa.String(length=50), nullable=True),
+		sa.PrimaryKeyConstraint('token', name=op.f('pk_signup'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('invite_grant', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('invite_token', sa.String(length=128), nullable=False),
+		sa.Column('user_dn', sa.String(length=128), nullable=False),
+		sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], name=op.f('fk_invite_grant_invite_token_invite')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_invite_grant'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('invite_roles', meta,
+		sa.Column('invite_token', sa.String(length=128), nullable=False),
+		sa.Column('role_id', sa.Integer(), nullable=False),
+		sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], name=op.f('fk_invite_roles_invite_token_invite')),
+		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_invite_roles_role_id_role')),
+		sa.PrimaryKeyConstraint('invite_token', 'role_id', name=op.f('pk_invite_roles'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('invite_signup', meta,
+		sa.Column('token', sa.String(length=128), nullable=False),
+		sa.Column('invite_token', sa.String(length=128), nullable=False),
+		sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], name=op.f('fk_invite_signup_invite_token_invite')),
+		sa.ForeignKeyConstraint(['token'], ['signup.token'], name=op.f('fk_invite_signup_token_signup')),
+		sa.PrimaryKeyConstraint('token', name=op.f('pk_invite_signup'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('role-group', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('dn', sa.String(length=128), nullable=True),
+		sa.Column('role_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role-group_role_id_role')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_role-group')),
+		sa.UniqueConstraint('dn', 'role_id', name=op.f('uq_role-group_dn'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('role-inclusion', meta,
+		sa.Column('role_id', sa.Integer(), nullable=False),
+		sa.Column('included_role_id', sa.Integer(), nullable=False),
+		sa.ForeignKeyConstraint(['included_role_id'], ['role.id'], name=op.f('fk_role-inclusion_included_role_id_role')),
+		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role-inclusion_role_id_role')),
+		sa.PrimaryKeyConstraint('role_id', 'included_role_id', name=op.f('pk_role-inclusion'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+	table = sa.Table('role-user', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('dn', sa.String(length=128), nullable=True),
+		sa.Column('role_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role-user_role_id_role')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_role-user')),
+		sa.UniqueConstraint('dn', 'role_id', name=op.f('uq_role-user_dn'))
+	)
+	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
+		pass
+
+def downgrade():
+	# upgrade only adds names to all constraints, no need to undo anything
+	pass
diff --git a/uffd/database.py b/uffd/database.py
index ad0cbcf40bb7b03c9cfef32e108f32d68225ec99..4f5413f94276d3dc45ce5af15afd8f824660b3fa 100644
--- a/uffd/database.py
+++ b/uffd/database.py
@@ -1,10 +1,20 @@
 from collections import OrderedDict
 
+from sqlalchemy import MetaData
 from flask_sqlalchemy import SQLAlchemy
 from flask.json import JSONEncoder
 
+convention = {
+	'ix': 'ix_%(column_0_label)s',
+	'uq': 'uq_%(table_name)s_%(column_0_name)s',
+	'ck': 'ck_%(table_name)s_%(column_0_name)s',
+	'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
+	'pk': 'pk_%(table_name)s'
+}
+metadata = MetaData(naming_convention=convention)
+
 # pylint: disable=C0103
-db = SQLAlchemy()
+db = SQLAlchemy(metadata=metadata)
 # pylint: enable=C0103
 
 class SQLAlchemyJSON(JSONEncoder):
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 5ac85c41cf9e18037d07b37572a8b1a1d11dc766..3a48fc2ae4bd867c2038995abcc95e04ed9d084f 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -65,6 +65,8 @@ SELF_SIGNUP=False
 # PASSWORDRESET is not available when not using a service connection
 ENABLE_PASSWORDRESET=True
 
+LOGINNAME_BLACKLIST=['^admin$', '^root$']
+
 #MFA_ICON_URL = 'https://example.com/logo.png'
 #MFA_RP_ID = 'example.com' # If unset, hostname from current request is used
 MFA_RP_NAME = 'Uffd Test Service' # Service name passed to U2F/FIDO2 authenticators
diff --git a/uffd/user/models.py b/uffd/user/models.py
index e9f0ea7a64afcd4ca5d7769aa58c2f70d578dd3e..cb44990a42b63b20030b7e1e4e691b8666de039e 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -1,5 +1,6 @@
 import secrets
 import string
+import re
 
 from flask import current_app
 from ldap3.utils.hashed import hashed, HASHED_SALTED_SHA512
@@ -87,12 +88,16 @@ class BaseUser(ldap.Model):
 				return True
 		return False
 
-	def set_loginname(self, value):
+	def set_loginname(self, value, ignore_blacklist=False):
 		if len(value) > 32 or len(value) < 1:
 			return False
 		for char in value:
 			if not char in string.ascii_lowercase + string.digits + '_-':
 				return False
+		if not ignore_blacklist:
+			for expr in current_app.config['LOGINNAME_BLACKLIST']:
+				if re.match(expr, value):
+					return False
 		self.loginname = value
 		return True
 
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user.html
index e289ea8d38c9d41936340c0ea106ca03dd97b64b..b2feedf9a0417e59636d3c19bc8303342a211ea2 100644
--- a/uffd/user/templates/user.html
+++ b/uffd/user/templates/user.html
@@ -29,28 +29,36 @@
 			<div class="form-group col">
 				<label for="user-uid">uid</label>
 				{% if user.uid %}
-				<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid }}" readonly>
+				<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid or '' }}" readonly>
 				{% else %}
 				<input type="text" class="form-control" id="user-uid" name="uid" placeholder="will be choosen" readonly>
 				{% endif %}
 			</div>
 			<div class="form-group col">
 				<label for="user-loginname">Login Name</label>
-				<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname }}" {% if user.uid %}readonly{% endif %}>
+				<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname or '' }}" {% if user.uid %}readonly{% endif %}>
 				<small class="form-text text-muted">
 					Only letters, numbers and underscore ("_") are allowed. At most 32, at least 2 characters. There is a word blacklist. Musst be unique.
 				</small>
 			</div>
+			{% if not user.uid %}
+			<div class="form-group col">
+				<div class="form-check">
+					<input class="form-check-input" type="checkbox" id="ignore-loginname-blacklist" name="ignore-loginname-blacklist" value="1" aria-label="enabled">
+					<label class="form-check-label" for="ignore-loginname-blacklist">Ignore login name blacklist</label>
+				</div>
+			</div>
+			{% endif %}
 			<div class="form-group col">
 				<label for="user-loginname">Display Name</label>
-				<input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ user.displayname }}">
+				<input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ user.displayname or '' }}">
 				<small class="form-text text-muted">
 					If you leave this empty it will be set to the login name. At most 128, at least 2 characters. No character restrictions.
 				</small>
 			</div>
 			<div class="form-group col">
 				<label for="user-mail">Mail</label>
-				<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail }}">
+				<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail or '' }}">
 				<small class="form-text text-muted">
 					Do a sanity check here. A user can take over another account if both have the same mail address set.
 				</small>
diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user_list.html
index 9e420323bf8cbe7824d4a76a93a9a9dfa49e30bc..ddcaab3e89023d8fce96db1bf59a7b45bf5eeccd 100644
--- a/uffd/user/templates/user_list.html
+++ b/uffd/user/templates/user_list.html
@@ -69,6 +69,10 @@ testuser5,foobadfar@example.com,0;5;2
 testuser2,foobaadsfr@example.com,5;2
 				</pre>
 				<textarea rows="10" class="form-control" name="csv"></textarea>
+				<div class="form-check mt-2">
+					<input class="form-check-input" type="checkbox" id="ignore-loginname-blacklist" name="ignore-loginname-blacklist" value="1" aria-label="enabled">
+					<label class="form-check-label" for="ignore-loginname-blacklist">Ignore login name blacklist</label>
+				</div>
 			</div>
 			<div class="modal-footer">
 				<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index 16060b8255cef2221643d8e59fb144c89813fc77..e25f89e7df1a197701ca17c8a52dc33c37b7bf77 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -41,7 +41,8 @@ def show(uid=None):
 def update(uid=None):
 	if uid is None:
 		user = User()
-		if not user.set_loginname(request.form['loginname']):
+		ignore_blacklist = request.form.get('ignore-loginname-blacklist', False)
+		if not user.set_loginname(request.form['loginname'], ignore_blacklist=ignore_blacklist):
 			flash('Login name does not meet requirements')
 			return redirect(url_for('user.show'))
 	else:
@@ -90,6 +91,8 @@ def csvimport():
 		flash('No data for csv import!')
 		return redirect(url_for('user.index'))
 
+	ignore_blacklist = request.values.get('ignore-loginname-blacklist', False)
+
 	roles = Role.query.all()
 	usersadded = 0
 	with io.StringIO(initial_value=csvdata) as csvfile:
@@ -99,7 +102,7 @@ def csvimport():
 				flash("invalid line, ignored : {}".format(row))
 				continue
 			newuser = User()
-			if not newuser.set_loginname(row[0]) or not newuser.set_displayname(row[0]):
+			if not newuser.set_loginname(row[0], ignore_blacklist=ignore_blacklist) or not newuser.set_displayname(row[0]):
 				flash("invalid login name, skipped : {}".format(row))
 				continue
 			if not newuser.set_mail(row[1]):