diff --git a/README.md b/README.md
index 8bca22a345a07c3364b944a5d35167e632fc56db..27b4c8437ec8eeb60a29a75adb1997a8a89360f3 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,17 @@ Upgrading will not perform any write access to the LDAP server.
 
 If the config option `ACL_SELFSERVICE_GROUP` is set but not `ACL_ACCESS_GROUP`, make sure to set `ACL_ACCESS_GROUP` to the same value as `ACL_SELFSERVICE_GROUP`,
 
+OAuth2 and API client definitions moved from the config (`OAUTH2_CLIENTS` and `API_CLIENTS_2`) to the database.
+The database migration automatically imports clients from the config.
+After upgrading the config options should be removed.
+
+Note that the `login_message` option is no longer supported for OAuth2 clients.
+The `required_group` is only correctly imported if it is set to a single group name (or absent).
+
+Also note that uffd can group OAuth2 and API clients of a service together.
+Set the `service_name` key in `OAUTH2_CLIENTS` and `API_CLIENTS_2` items to the same value to group them together.
+Without this key the import creates individual service objects for each client.
+
 ## Python Coding Style Conventions
 
 PEP 8 without double new lines, tabs instead of spaces and a max line length of 160 characters.
diff --git a/check_migrations.py b/check_migrations.py
index 9ccf98d6be204efa8a08431735401a5e1aa28c99..70f5d25796ddcf7ea4484f11b9c69469b84dcfc0 100755
--- a/check_migrations.py
+++ b/check_migrations.py
@@ -13,7 +13,8 @@ from uffd.role.models import Role, RoleGroup
 from uffd.signup.models import Signup
 from uffd.invite.models import Invite, InviteGrant, InviteSignup
 from uffd.session.models import DeviceLoginConfirmation
-from uffd.oauth2.models import OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
+from uffd.service.models import Service
+from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
 from uffd.selfservice.models import PasswordToken, MailToken
 
 def run_test(dburi, revision):
@@ -48,9 +49,12 @@ def run_test(dburi, revision):
 		invite.signups.append(InviteSignup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword'))
 		invite.grants.append(InviteGrant(user=user))
 		db.session.add(Invite(creator=user, valid_until=datetime.datetime.now()))
-		db.session.add(OAuth2Grant(user=user, client_id='testclient', code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now()))
-		db.session.add(OAuth2Token(user=user, client_id='testclient', token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now()))
-		db.session.add(OAuth2DeviceLoginInitiation(oauth2_client_id='testclient', confirmations=[DeviceLoginConfirmation(user=user)]))
+		service = Service(name='testservice', access_group=group)
+		oauth2_client = OAuth2Client(service=service, client_id='testclient', client_secret='testsecret', redirect_uris=['http://localhost:1234/callback'], logout_uris=[OAuth2LogoutURI(method='GET', uri='http://localhost:1234/callback')])
+		db.session.add_all([service, oauth2_client])
+		db.session.add(OAuth2Grant(user=user, client=oauth2_client, code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now()))
+		db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now()))
+		db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)]))
 		db.session.add(PasswordToken(user=user))
 		db.session.add(MailToken(user=user, newmail='test@example.com'))
 		db.session.commit()
diff --git a/tests/test_api.py b/tests/test_api.py
index b01a6ff3eef261a1148a98d01742fa2ca007fe89..81f02480fdf52221bfa1ad7b2bed695f0b06a5ee 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -3,6 +3,8 @@ import base64
 from flask import url_for
 
 from uffd.api.views import apikey_required
+from uffd.api.models import APIClient
+from uffd.service.models import Service
 from uffd.user.models import User
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.database import db
@@ -13,33 +15,25 @@ def basic_auth(username, password):
 
 class TestAPIAuth(UffdTestCase):
 	def setUpApp(self):
-		self.app.config['API_CLIENTS_2'] = {
-			'test1': {'client_secret': 'testsecret1', 'scopes': ['getusers', 'testscope']},
-			'test2': {'client_secret': 'testsecret2'},
-		}
-
 		@self.app.route('/test/endpoint1')
 		@apikey_required()
 		def testendpoint1():
 			return 'OK', 200
 
 		@self.app.route('/test/endpoint2')
-		@apikey_required('getusers')
+		@apikey_required('users')
 		def testendpoint2():
 			return 'OK', 200
 
-		@self.app.route('/test/endpoint3')
-		@apikey_required('testscope')
-		def testendpoint3():
-			return 'OK', 200
+	def setUpDB(self):
+		db.session.add(APIClient(service=Service(name='test1'), auth_username='test1', auth_password='testsecret1', perm_users=True))
+		db.session.add(APIClient(service=Service(name='test2'), auth_username='test2', auth_password='testsecret2'))
 
 	def test_basic(self):
 		r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.get(path=url_for('testendpoint2'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
-		r = self.client.get(path=url_for('testendpoint3'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True)
-		self.assertEqual(r.status_code, 200)
 		r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 
@@ -52,18 +46,26 @@ class TestAPIAuth(UffdTestCase):
 	def test_basic_missing_scope(self):
 		r = self.client.get(path=url_for('testendpoint2'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True)
 		self.assertEqual(r.status_code, 403)
-		r = self.client.get(path=url_for('testendpoint3'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True)
-		self.assertEqual(r.status_code, 403)
 
 	def test_no_auth(self):
 		r = self.client.get(path=url_for('testendpoint1'), follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
 
+	def test_auth_password_rehash(self):
+		db.session.add(APIClient(service=Service(name='test3'), auth_username='test3', auth_password=PlaintextPasswordHash.from_password('testsecret3')))
+		db.session.commit()
+		self.assertIsInstance(APIClient.query.filter_by(auth_username='test3').one().auth_password, PlaintextPasswordHash)
+		r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test3', 'testsecret3')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		api_client = APIClient.query.filter_by(auth_username='test3').one()
+		self.assertIsInstance(api_client.auth_password, APIClient.auth_password.method_cls)
+		self.assertTrue(api_client.auth_password.verify('testsecret3'))
+		r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test3', 'testsecret3')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+
 class TestAPIGetmails(UffdTestCase):
-	def setUpApp(self):
-		self.app.config['API_CLIENTS_2'] = {
-			'test': {'client_secret': 'test', 'scopes': ['getmails']},
-		}
+	def setUpDB(self):
+		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_mail_aliases=True))
 
 	def test_lookup(self):
 		r = self.client.get(path=url_for('api.getmails', receive_address='test1@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True)
@@ -84,10 +86,8 @@ class TestAPIGetmails(UffdTestCase):
 		self.assertEqual(r.json, [{'name': 'test', 'receive_addresses': ['test1@example.com', 'test2@example.com'], 'destination_addresses': ['testuser@mail.example.com']}])
 
 class TestAPICheckPassword(UffdTestCase):
-	def setUpApp(self):
-		self.app.config['API_CLIENTS_2'] = {
-			'test': {'client_secret': 'test', 'scopes': ['checkpassword']},
-		}
+	def setUpDB(self):
+		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_checkpassword=True))
 
 	def test(self):
 		r = self.client.post(path=url_for('api.checkpassword'), data={'loginname': 'testuser', 'password': 'userpassword'}, headers=[basic_auth('test', 'test')])
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index 47a97f2ae66b654bb5a495023f7ce5c40f64e900..d3e68dfec7c330d2b44fee1e4d461ccc5616d37b 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -7,46 +7,18 @@ from flask import url_for, session
 from uffd import user
 
 from uffd.user.models import User
+from uffd.password_hash import PlaintextPasswordHash
 from uffd.session.models import DeviceLoginConfirmation
+from uffd.service.models import Service
 from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
 from uffd import create_app, db
 
 from utils import dump, UffdTestCase
 
-
-class TestOAuth2Client(UffdTestCase):
-	def setUpApp(self):
-		self.app.config['OAUTH2_CLIENTS'] = {
-			'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']},
-			'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'users'},
-		}
-
-	def test_from_id(self):
-		client = OAuth2Client.from_id('test')
-		self.assertEqual(client.client_id, 'test')
-		self.assertEqual(client.client_secret, 'testsecret')
-		self.assertEqual(client.redirect_uris, ['http://localhost:5009/callback', 'http://localhost:5009/callback2'])
-		self.assertEqual(client.default_redirect_uri, 'http://localhost:5009/callback')
-		self.assertEqual(client.default_scopes, ['profile'])
-		self.assertEqual(client.client_type, 'confidential')
-		client = OAuth2Client.from_id('test1')
-		self.assertEqual(client.client_id, 'test1')
-		self.assertEqual(client.required_group, 'users')
-
-	def test_access_allowed(self):
-		user = self.get_user() # has 'users' and 'uffd_access' group
-		admin = self.get_admin() # has 'users', 'uffd_access' and 'uffd_admin' group
-		client = OAuth2Client('test', '', [''], ['uffd_admin', ['users', 'notagroup']])
-		self.assertFalse(client.access_allowed(user))
-		self.assertTrue(client.access_allowed(admin))
-		# More required_group values are tested by TestUserModel.test_has_permission
-
 class TestViews(UffdTestCase):
-	def setUpApp(self):
-		self.app.config['OAUTH2_CLIENTS'] = {
-			'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']},
-			'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'},
-		}
+	def setUpDB(self):
+		db.session.add(OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2']))
+		db.session.add(OAuth2Client(service=Service(name='test1', access_group=self.get_admin_group()), client_id='test1', client_secret='testsecret1', redirect_uris=['http://localhost:5008/callback']))
 
 	def assert_authorization(self, r):
 		while True:
@@ -80,6 +52,18 @@ class TestViews(UffdTestCase):
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
 		self.assert_authorization(r)
 
+	def test_authorization_client_secret_rehash(self):
+		OAuth2Client.query.delete()
+		db.session.add(OAuth2Client(service=Service(name='rehash_test', limit_access=False), client_id='test', client_secret=PlaintextPasswordHash.from_password('testsecret'), redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2']))
+		db.session.commit()
+		self.assertIsInstance(OAuth2Client.query.filter_by(client_id='test').one().client_secret, PlaintextPasswordHash)
+		self.login_as('user')
+		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
+		self.assert_authorization(r)
+		oauth2_client = OAuth2Client.query.filter_by(client_id='test').one()
+		self.assertIsInstance(oauth2_client.client_secret, OAuth2Client.client_secret.method_cls)
+		self.assertTrue(oauth2_client.client_secret.verify('testsecret'))
+
 	def test_authorization_without_redirect_uri(self):
 		self.login_as('user')
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='profile'), follow_redirects=False)
@@ -133,12 +117,12 @@ class TestViews(UffdTestCase):
 		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.assertEqual(initiation.client.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')
+			initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one())
 			db.session.add(initiation)
 			confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user())
 			db.session.add(confirmation)
diff --git a/tests/test_services.py b/tests/test_services.py
index 89b83fb28d2eb149da770beb35002817e63e76d4..269d1455471c70932c26ff9770d5db5d8c7b0ba6 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -40,14 +40,14 @@ class TestServices(UffdTestCase):
 		]
 		self.app.config['SERVICES_PUBLIC'] = True
 
-	def test_index(self):
-		r = self.client.get(path=url_for('services.index'))
-		dump('services_index_public', r)
+	def test_overview(self):
+		r = self.client.get(path=url_for('service.overview'))
+		dump('service_overview_public', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertNotIn(b'https://example.com/', r.data)
 		self.login_as('user')
-		r = self.client.get(path=url_for('services.index'))
-		dump('services_index', r)
+		r = self.client.get(path=url_for('service.overview'))
+		dump('service_overview', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertIn(b'https://example.com/', r.data)
 
diff --git a/tests/test_session.py b/tests/test_session.py
index 711c76b9482bb943c9512053119438a50a9ec145..173adfd13d669e550c71f62e78b7ff0b55a055c1 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -8,7 +8,8 @@ from uffd import user
 
 from uffd.session.views import login_required
 from uffd.session.models import DeviceLoginConfirmation
-from uffd.oauth2.models import OAuth2DeviceLoginInitiation
+from uffd.service.models import Service
+from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
 from uffd.user.models import User
 from uffd.password_hash import PlaintextPasswordHash
 from uffd import create_app, db
@@ -161,10 +162,8 @@ class TestSession(UffdTestCase):
 		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')
+		oauth2_client = OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2'])
+		initiation = OAuth2DeviceLoginInitiation(client=oauth2_client)
 		db.session.add(initiation)
 		db.session.commit()
 		code = initiation.code
diff --git a/tests/utils.py b/tests/utils.py
index 0fd6fbb85896107ea96c0cac1a183d3255464279..b043f3afe37e05e6b5cdeaf81d12ad2373b722e0 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -87,10 +87,14 @@ class UffdTestCase(unittest.TestCase):
 		db.session.add(testadmin)
 		testmail = Mail(uid='test', receivers=['test1@example.com', 'test2@example.com'], destinations=['testuser@mail.example.com'])
 		db.session.add(testmail)
+		self.setUpDB()
 		db.session.commit()
 
 	def setUpApp(self):
 		pass
 
+	def setUpDB(self):
+		pass
+
 	def tearDown(self):
 		self.client.__exit__(None, None, None)
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 03ba6d42569c7f5f311b35889d8197472891772a..8831fbdb73820e87cc14df91a8fa06bb89de1e5d 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -18,7 +18,7 @@ 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
-from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup, rolemod, invite, api
+from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, service, signup, rolemod, invite, api
 from uffd.user.models import User, Group
 from uffd.role.models import Role, RoleGroup
 from uffd.mail.models import Mail
@@ -68,7 +68,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta
 	register_template_helper(app)
 
 	# Sort the navbar positions by their blueprint names (from the left)
-	positions = ["selfservice", "services", "rolemod", "invite", "user", "group", "role", "mail"]
+	positions = ["selfservice", "service", "rolemod", "invite", "user", "group", "role", "mail"]
 	setup_navbar(app, positions)
 
 	# We never want to fail here, but at a file access that doesn't work.
@@ -85,7 +85,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta
 
 	cleanup_task.init_app(app, db)
 
-	for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, services, rolemod, api, signup, invite]:
+	for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, service, rolemod, api, signup, invite]:
 		for bp in module.bp:
 			app.register_blueprint(bp)
 
diff --git a/uffd/api/models.py b/uffd/api/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..391a0aed2391c741e5418593e42b44e466b1a483
--- /dev/null
+++ b/uffd/api/models.py
@@ -0,0 +1,26 @@
+from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text
+from sqlalchemy.orm import relationship
+
+from uffd.database import db
+from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
+
+class APIClient(db.Model):
+	__tablename__ = 'api_client'
+	id = Column(Integer, primary_key=True, autoincrement=True)
+	service_id = Column(Integer, ForeignKey('service.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	service = relationship('Service', back_populates='api_clients')
+	auth_username = Column(String(40), unique=True, nullable=False)
+	_auth_password = Column('auth_password', Text(), nullable=False)
+	auth_password = PasswordHashAttribute('_auth_password', HighEntropyPasswordHash)
+
+	# Permissions are defined by adding an attribute named "perm_NAME"
+	perm_users = Column(Boolean(), default=False, nullable=False)
+	perm_checkpassword = Column(Boolean(), default=False, nullable=False)
+	perm_mail_aliases = Column(Boolean(), default=False, nullable=False)
+
+	@classmethod
+	def permission_exists(cls, name):
+		return hasattr(cls, 'perm_'+name)
+
+	def has_permission(self, name):
+		return getattr(self, 'perm_' + name)
diff --git a/uffd/api/views.py b/uffd/api/views.py
index 16446fe79b76c8e9f8f81cc432daef6edaea2653..208b7ff77b5c3ca13e8699676ef6943985cbc5d4 100644
--- a/uffd/api/views.py
+++ b/uffd/api/views.py
@@ -1,30 +1,34 @@
 import functools
-import secrets
 
-from flask import Blueprint, jsonify, current_app, request, abort
+from flask import Blueprint, jsonify, request, abort
 
 from uffd.user.models import User, Group
 from uffd.mail.models import Mail, MailReceiveAddress, MailDestinationAddress
+from uffd.api.models import APIClient
 from uffd.session.views import login_get_user, login_ratelimit
 from uffd.database import db
 
 bp = Blueprint('api', __name__, template_folder='templates', url_prefix='/api/v1/')
 
-def apikey_required(scope=None):
+def apikey_required(permission=None):
 	# pylint: disable=too-many-return-statements
+	if permission is not None:
+		assert APIClient.permission_exists(permission)
 	def wrapper(func):
 		@functools.wraps(func)
 		def decorator(*args, **kwargs):
-			if request.authorization and request.authorization.password:
-				if request.authorization.username not in current_app.config['API_CLIENTS_2']:
-					return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
-				client = current_app.config['API_CLIENTS_2'][request.authorization.username]
-				if not secrets.compare_digest(request.authorization.password, client['client_secret']):
-					return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
-				if scope is not None and scope not in client.get('scopes', []):
-					return 'Forbidden', 403
-			else:
+			if not request.authorization or not request.authorization.password:
 				return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
+			client = APIClient.query.filter_by(auth_username=request.authorization.username).first()
+			if not client:
+				return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
+			if not client.auth_password.verify(request.authorization.password):
+				return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']}
+			if client.auth_password.needs_rehash:
+				client.auth_password = request.authorization.password
+				db.session.commit()
+			if permission is not None and not client.has_permission(permission):
+				return 'Forbidden', 403
 			return func(*args, **kwargs)
 		return decorator
 	return wrapper
@@ -34,7 +38,7 @@ def generate_group_dict(group):
 	        'members': [user.loginname for user in group.members]}
 
 @bp.route('/getgroups', methods=['GET', 'POST'])
-@apikey_required('getusers')
+@apikey_required('users')
 def getgroups():
 	if len(request.values) > 1:
 		abort(400)
@@ -60,7 +64,7 @@ def generate_user_dict(user, all_groups=None):
 	        'groups': [group.name for group in all_groups if user in group.members]}
 
 @bp.route('/getusers', methods=['GET', 'POST'])
-@apikey_required('getusers')
+@apikey_required('users')
 def getusers():
 	if len(request.values) > 1:
 		abort(400)
@@ -108,7 +112,7 @@ def generate_mail_dict(mail):
 	        'destination_addresses': list(mail.destinations)}
 
 @bp.route('/getmails', methods=['GET', 'POST'])
-@apikey_required('getmails')
+@apikey_required('mail_aliases')
 def getmails():
 	if len(request.values) > 1:
 		abort(400)
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 718e4933f674ad670546d804131a71c18adf8fb2..6546b0024d95cc9f5665055e7f99e25da6bc95e1 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -51,22 +51,6 @@ SQLALCHEMY_TRACK_MODIFICATIONS=False
 
 FOOTER_LINKS=[{"url": "https://example.com", "title": "example"}]
 
-OAUTH2_CLIENTS={
-	#'test_client_id' : {'client_secret': 'random_secret', 'redirect_uris': ['https://example.com/oauth']},
-	# You can optionally restrict access to users with a certain group. Set 'required_group' to the name a group or a list of group names.
-	# ... 'required_group': 'test_access_group' ... only allows users with group "test_access_group" access
-	# ... 'required_group': ['groupa', ['groupb', 'groupc']] ... allows users with group "groupa" as well as users with both "groupb" and "groupc" access
-	# Set 'login_message' (or suffixed with a language code like 'login_message_de') to display a custom message on the login form.
-}
-
-API_CLIENTS_2={
-	#'test_client_id' : {'client_secret': 'random_secret', 'scopes': ['users', 'checkpassword']},
-	# Scopes:
-	# * 'getusers': Retrieve user and group data (endpoints getusers and getgroups)
-	# * 'checkpassword': Check user login password
-	# * 'getmails': Retrieve mail aliases
-}
-
 # Service overview page (disabled if empty)
 SERVICES=[
 #	# Title is mandatory, all other fields are optional.
diff --git a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a2c47b79c19636ff65418cc89c1212435bedbd8
--- /dev/null
+++ b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py
@@ -0,0 +1,309 @@
+"""Move API and OAuth2 clients to DB
+
+Revision ID: b9d3f7dac9db
+Revises: 09d2edcaf0cc
+Create Date: 2022-02-17 21:14:00.440057
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from flask import current_app
+
+revision = 'b9d3f7dac9db'
+down_revision = '09d2edcaf0cc'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	used_service_names = set()
+	services = {} # name -> limit_access, access_group_name
+	oauth2_clients = [] # service_name, client_id, client_secret, redirect_uris, logout_uris
+	api_clients = [] # service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases
+	for opts in current_app.config.get('OAUTH2_CLIENTS', {}).values():
+		if 'service_name' in opts:
+			used_service_names.add(opts['service_name'])
+	for opts in current_app.config.get('API_CLIENTS_2', {}).values():
+		if 'service_name' in opts:
+			used_service_names.add(opts['service_name'])
+	for client_id, opts in current_app.config.get('OAUTH2_CLIENTS', {}).items():
+		if 'client_secret' not in opts:
+			continue
+		if 'service_name' in opts:
+			service_name = opts['service_name']
+		else:
+			service_name = client_id
+			if service_name in used_service_names:
+				service_name = 'oauth2_' + service_name
+			if service_name in used_service_names:
+				num = 1
+				while (service_name + '_%d'%num) in used_service_names:
+					num += 1
+				service_name = service_name + '_%d'%num
+		if opts.get('required_group') is None:
+			limit_access = False
+			access_group_name = None
+		elif isinstance(opts.get('required_group'), str):
+			limit_access = True
+			access_group_name = opts['required_group']
+		else:
+			limit_access = True
+			access_group_name = None
+		client_secret = opts['client_secret']
+		redirect_uris = opts.get('redirect_uris') or []
+		logout_uris = []
+		for item in opts.get('logout_urls') or []:
+			if isinstance(item, str):
+				logout_uris.append(('GET', item))
+			else:
+				logout_uris.append(item)
+		used_service_names.add(service_name)
+		if service_name not in services or services[service_name] == (False, None):
+			services[service_name] = (limit_access, access_group_name)
+		elif services[service_name] == (limit_access, access_group_name):
+			pass
+		else:
+			services[service_name] = (True, None)
+		oauth2_clients.append((service_name, client_id, client_secret, redirect_uris, logout_uris))
+	for client_id, opts in current_app.config.get('API_CLIENTS_2', {}).items():
+		if 'client_secret' not in opts:
+			continue
+		if 'service_name' in opts:
+			service_name = opts['service_name']
+		else:
+			service_name = 'api_' + client_id
+			if service_name in used_service_names:
+				num = 1
+				while (service_name + '_%d'%num) in used_service_names:
+					num += 1
+				service_name = service_name + '_%d'%num
+		auth_username = client_id
+		auth_password = opts['client_secret']
+		perm_users = 'getusers' in opts.get('scopes', [])
+		perm_checkpassword = 'checkpassword' in opts.get('scopes', [])
+		perm_mail_aliases = 'getmails' in opts.get('scopes', [])
+		if service_name not in services:
+			services[service_name] = (False, None)
+		api_clients.append((service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases))
+
+	meta = sa.MetaData(bind=op.get_bind())
+
+	service_table = op.create_table('service',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=255), nullable=False),
+		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('access_group_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
+		sa.UniqueConstraint('name', name=op.f('uq_service_name'))
+	)
+	group_table = sa.table('group',
+		sa.column('id'),
+		sa.column('name'),
+	)
+	for service_name, args in services.items():
+		limit_access, access_group_name = args
+		op.execute(service_table.insert().values(name=service_name, limit_access=limit_access, access_group_id=sa.select([group_table.c.id]).where(group_table.c.name==access_group_name).as_scalar()))
+
+	api_client_table = op.create_table('api_client',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('service_id', sa.Integer(), nullable=False),
+		sa.Column('auth_username', sa.String(length=40), nullable=False),
+		sa.Column('auth_password', sa.Text(), nullable=False),
+		sa.Column('perm_users', sa.Boolean(), nullable=False),
+		sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
+		sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
+		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
+		sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
+	)
+	for service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases in api_clients:
+		op.execute(api_client_table.insert().values(service_id=sa.select([service_table.c.id]).where(service_table.c.name==service_name).as_scalar(), auth_username=auth_username, auth_password=auth_password, perm_users=perm_users, perm_checkpassword=perm_checkpassword, perm_mail_aliases=perm_mail_aliases))
+
+	oauth2client_table = op.create_table('oauth2client',
+		sa.Column('db_id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('service_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=False),
+		sa.Column('client_secret', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_oauth2client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('db_id', name=op.f('pk_oauth2client')),
+		sa.UniqueConstraint('client_id', name=op.f('uq_oauth2client_client_id'))
+	)
+	oauth2logout_uri_table = op.create_table('oauth2logout_uri',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('client_db_id', sa.Integer(), nullable=False),
+		sa.Column('method', sa.String(length=40), nullable=False),
+		sa.Column('uri', sa.String(length=255), nullable=False),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2logout_uri_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2logout_uri'))
+	)
+	oauth2redirect_uri_table = op.create_table('oauth2redirect_uri',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('client_db_id', sa.Integer(), nullable=False),
+		sa.Column('uri', sa.String(length=255), nullable=False),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2redirect_uri_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2redirect_uri'))
+	)
+	for service_name, client_id, client_secret, redirect_uris, logout_uris in oauth2_clients:
+		op.execute(oauth2client_table.insert().values(service_id=sa.select([service_table.c.id]).where(service_table.c.name==service_name).as_scalar(), client_id=client_id, client_secret=client_secret))
+		for method, uri, in logout_uris:
+			op.execute(oauth2logout_uri_table.insert().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2client_table.c.client_id==client_id).as_scalar(), method=method, uri=uri))
+		for uri in redirect_uris:
+			op.execute(oauth2redirect_uri_table.insert().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2client_table.c.client_id==client_id).as_scalar(), uri=uri))
+
+	with op.batch_alter_table('device_login_initiation', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('oauth2_client_db_id', sa.Integer(), nullable=True))
+		batch_op.create_foreign_key(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), 'oauth2client', ['oauth2_client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE')
+	device_login_initiation_table = sa.Table('device_login_initiation', meta,
+		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.Column('oauth2_client_db_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['oauth2_client_db_id'], ['oauth2client.db_id'], name=op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		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.execute(device_login_initiation_table.update().values(oauth2_client_db_id=sa.select([oauth2client_table.c.db_id]).where(device_login_initiation_table.c.oauth2_client_id==oauth2client_table.c.client_id).as_scalar()))
+	op.execute(device_login_initiation_table.delete().where(device_login_initiation_table.c.oauth2_client_db_id==None))
+	with op.batch_alter_table('device_login_initiation', copy_from=device_login_initiation_table) as batch_op:
+		batch_op.drop_column('oauth2_client_id')
+
+	with op.batch_alter_table('oauth2grant', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('client_db_id', sa.Integer(), nullable=True))
+		batch_op.create_foreign_key(batch_op.f('fk_oauth2grant_client_db_id_oauth2client'), 'oauth2client', ['client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE')
+	oauth2grant_table = sa.Table('oauth2grant', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=False),
+		sa.Column('client_db_id', sa.Integer(), nullable=True),
+		sa.Column('code', sa.String(length=255), nullable=False),
+		sa.Column('redirect_uri', sa.String(length=255), nullable=False),
+		sa.Column('expires', sa.DateTime(), nullable=False),
+		sa.Column('_scopes', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')),
+		sa.Index('ix_oauth2grant_code', 'code')
+	)
+	op.execute(oauth2grant_table.update().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2grant_table.c.client_id==oauth2client_table.c.client_id).as_scalar()))
+	op.execute(oauth2grant_table.delete().where(oauth2grant_table.c.client_db_id==None))
+	with op.batch_alter_table('oauth2grant', copy_from=oauth2grant_table) as batch_op:
+		batch_op.alter_column('client_db_id', existing_type=sa.Integer(), nullable=False)
+		batch_op.drop_column('client_id')
+
+	with op.batch_alter_table('oauth2token', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('client_db_id', sa.Integer(), nullable=True))
+		batch_op.create_foreign_key(batch_op.f('fk_oauth2token_client_db_id_oauth2client'), 'oauth2client', ['client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE')
+	oauth2token_table = sa.Table('oauth2token', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=False),
+		sa.Column('client_db_id', sa.Integer(), nullable=True),
+		sa.Column('token_type', sa.String(length=40), nullable=False),
+		sa.Column('access_token', sa.String(length=255), nullable=False),
+		sa.Column('refresh_token', sa.String(length=255), nullable=False),
+		sa.Column('expires', sa.DateTime(), nullable=False),
+		sa.Column('_scopes', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		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'))
+	)
+	op.execute(oauth2token_table.update().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2token_table.c.client_id==oauth2client_table.c.client_id).as_scalar()))
+	op.execute(oauth2token_table.delete().where(oauth2token_table.c.client_db_id==None))
+	with op.batch_alter_table('oauth2token', copy_from=oauth2token_table) as batch_op:
+		batch_op.alter_column('client_db_id', existing_type=sa.Integer(), nullable=False)
+		batch_op.drop_column('client_id')
+
+def downgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	oauth2client_table = sa.Table('oauth2client', meta,
+		sa.Column('db_id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('service_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=False),
+		sa.Column('client_secret', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_oauth2client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('db_id', name=op.f('pk_oauth2client')),
+		sa.UniqueConstraint('client_id', name=op.f('uq_oauth2client_client_id'))
+	)
+
+	with op.batch_alter_table('oauth2token', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('client_id', sa.VARCHAR(length=40), nullable=True))
+	oauth2token_table = sa.Table('oauth2token', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=True),
+		sa.Column('client_db_id', sa.Integer(), nullable=False),
+		sa.Column('token_type', sa.String(length=40), nullable=False),
+		sa.Column('access_token', sa.String(length=255), nullable=False),
+		sa.Column('refresh_token', sa.String(length=255), nullable=False),
+		sa.Column('expires', sa.DateTime(), nullable=False),
+		sa.Column('_scopes', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		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'))
+	)
+	op.execute(oauth2token_table.update().values(client_id=sa.select([oauth2client_table.c.client_id]).where(oauth2token_table.c.client_db_id==oauth2client_table.c.db_id).as_scalar()))
+	op.execute(oauth2token_table.delete().where(oauth2token_table.c.client_id==None))
+	with op.batch_alter_table('oauth2token', copy_from=oauth2token_table) as batch_op:
+		batch_op.alter_column('client_id', existing_type=sa.VARCHAR(length=40), nullable=False)
+		batch_op.drop_constraint(batch_op.f('fk_oauth2token_client_db_id_oauth2client'), type_='foreignkey')
+		batch_op.drop_column('client_db_id')
+
+	with op.batch_alter_table('oauth2grant', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('client_id', sa.VARCHAR(length=40), nullable=True))
+	oauth2grant_table = sa.Table('oauth2grant', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('client_id', sa.String(length=40), nullable=True),
+		sa.Column('client_db_id', sa.Integer(), nullable=False),
+		sa.Column('code', sa.String(length=255), nullable=False),
+		sa.Column('redirect_uri', sa.String(length=255), nullable=False),
+		sa.Column('expires', sa.DateTime(), nullable=False),
+		sa.Column('_scopes', sa.Text(), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')),
+		sa.Index('ix_oauth2grant_code', 'code')
+	)
+	op.execute(oauth2grant_table.update().values(client_id=sa.select([oauth2client_table.c.client_id]).where(oauth2grant_table.c.client_db_id==oauth2client_table.c.db_id).as_scalar()))
+	op.execute(oauth2grant_table.delete().where(oauth2grant_table.c.client_id==None))
+	with op.batch_alter_table('oauth2grant', copy_from=oauth2grant_table) as batch_op:
+		batch_op.alter_column('client_id', existing_type=sa.VARCHAR(length=40), nullable=False)
+		batch_op.drop_constraint(batch_op.f('fk_oauth2grant_client_db_id_oauth2client'), type_='foreignkey')
+		batch_op.drop_column('client_db_id')
+
+	with op.batch_alter_table('device_login_initiation', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('oauth2_client_id', sa.VARCHAR(length=40), nullable=True))
+	device_login_initiation_table = sa.Table('device_login_initiation', meta,
+		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.Column('oauth2_client_db_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['oauth2_client_db_id'], ['oauth2client.db_id'], name=op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'),
+		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.execute(device_login_initiation_table.update().values(oauth2_client_id=sa.select([oauth2client_table.c.client_id]).where(device_login_initiation_table.c.oauth2_client_db_id==oauth2client_table.c.db_id).as_scalar()))
+	op.execute(device_login_initiation_table.delete().where(device_login_initiation_table.c.oauth2_client_id==None))
+	with op.batch_alter_table('device_login_initiation', copy_from=device_login_initiation_table) as batch_op:
+		batch_op.drop_constraint(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), type_='foreignkey')
+		batch_op.drop_column('oauth2_client_db_id')
+
+	op.drop_table('oauth2redirect_uri')
+	op.drop_table('oauth2logout_uri')
+	op.drop_table('oauth2client')
+	op.drop_table('api_client')
+	op.drop_table('service')
diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py
index bae0f7ceec35aebf2067edcd13bed5ddd4d5ddd7..4621f1146130d2a6a43c5a45e6fe2c0d5643b742 100644
--- a/uffd/oauth2/models.py
+++ b/uffd/oauth2/models.py
@@ -1,47 +1,61 @@
 import datetime
 
-from flask import current_app
-from flask_babel import get_locale, gettext as _
-from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
+from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship
 from sqlalchemy.ext.hybrid import hybrid_property
+from sqlalchemy.ext.associationproxy import association_proxy
 
 from uffd.database import db, CommaSeparatedList
 from uffd.tasks import cleanup_task
+from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
 from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
 
-class OAuth2Client:
-	def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None, **kwargs):
-		self.client_id = client_id
-		self.client_secret = client_secret
-		# We only support the Authorization Code Flow for confidential (server-side) clients
-		self.client_type = 'confidential'
-		self.redirect_uris = redirect_uris
-		self.default_scopes = ['profile']
-		self.required_group = required_group
-		self.logout_urls = []
-		for url in (logout_urls or []):
-			if isinstance(url, str):
-				self.logout_urls.append(['GET', url])
-			else:
-				self.logout_urls.append(url)
-		self.kwargs = kwargs
+class OAuth2Client(db.Model):
+	__tablename__ = 'oauth2client'
+	# Inconsistently named "db_id" instead of "id" because of the naming conflict
+	# with "client_id" in the OAuth2 standard
+	db_id = Column(Integer, primary_key=True, autoincrement=True)
+
+	service_id = Column(Integer, ForeignKey('service.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	service = relationship('Service', back_populates='oauth2_clients')
+
+	client_id = Column(String(40), unique=True, nullable=False)
+	_client_secret = Column('client_secret', Text(), nullable=False)
+	client_secret = PasswordHashAttribute('_client_secret', HighEntropyPasswordHash)
+	_redirect_uris = relationship('OAuth2RedirectURI', cascade='all, delete-orphan')
+	redirect_uris = association_proxy('_redirect_uris', 'uri')
+	logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan')
 
 	@property
-	def login_message(self):
-		return self.kwargs.get('login_message_' + get_locale().language,
-		                       self.kwargs.pop('login_message', _('You need to login to access this service')))
+	def client_type(self):
+		return 'confidential'
 
-	@classmethod
-	def from_id(cls, client_id):
-		return OAuth2Client(client_id, **current_app.config['OAUTH2_CLIENTS'][client_id])
+	@property
+	def default_scopes(self):
+		return ['profile']
 
 	@property
 	def default_redirect_uri(self):
 		return self.redirect_uris[0]
 
 	def access_allowed(self, user):
-		return user.has_permission(self.required_group)
+		return self.service.has_access(user)
+
+class OAuth2RedirectURI(db.Model):
+	__tablename__ = 'oauth2redirect_uri'
+	id = Column(Integer, primary_key=True, autoincrement=True)
+	client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	uri = Column(String(255), nullable=False)
+
+	def __init__(self, uri):
+		self.uri = uri
+
+class OAuth2LogoutURI(db.Model):
+	__tablename__ = 'oauth2logout_uri'
+	id = Column(Integer, primary_key=True, autoincrement=True)
+	client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	method = Column(String(40), nullable=False, default='GET')
+	uri = Column(String(255), nullable=False)
 
 @cleanup_task.delete_by_attribute('expired')
 class OAuth2Grant(db.Model):
@@ -51,15 +65,8 @@ class OAuth2Grant(db.Model):
 	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
 	user = relationship('User')
 
-	client_id = Column(String(40), nullable=False)
-
-	@property
-	def client(self):
-		return OAuth2Client.from_id(self.client_id)
-
-	@client.setter
-	def client(self, newclient):
-		self.client_id = newclient.client_id
+	client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	client = relationship('OAuth2Client')
 
 	code = Column(String(255), index=True, nullable=False)
 	redirect_uri = Column(String(255), nullable=False)
@@ -80,15 +87,8 @@ class OAuth2Token(db.Model):
 	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
 	user = relationship('User')
 
-	client_id = Column(String(40), nullable=False)
-
-	@property
-	def client(self):
-		return OAuth2Client.from_id(self.client_id)
-
-	@client.setter
-	def client(self, newclient):
-		self.client_id = newclient.client_id
+	client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	client = relationship('OAuth2Client')
 
 	# currently only bearer is supported
 	token_type = Column(String(40), nullable=False)
@@ -109,12 +109,9 @@ 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)
+	client_db_id = Column('oauth2_client_db_id', Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'))
+	client = relationship('OAuth2Client')
 
 	@property
 	def description(self):
-		return self.oauth2_client.client_id
+		return self.client.service.name
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index b52e3600f600f463595f4bf4738aa8bed1ac2228..cfb089b3aeace41393a2da15cd8b0c8e891b4d4a 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -22,11 +22,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 	# before anything else. authenticate_client_id would be called instead of authenticate_client for non-confidential
 	# clients. However, we don't support those.
 	def validate_client_id(self, client_id, oauthreq, *args, **kwargs):
-		try:
-			oauthreq.client = OAuth2Client.from_id(client_id)
-			return True
-		except KeyError:
-			return False
+		oauthreq.client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none()
+		return oauthreq.client is not None
 
 	def authenticate_client(self, oauthreq, *args, **kwargs):
 		authorization = oauthreq.extra_credentials.get('authorization')
@@ -41,11 +38,15 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 			oauthreq.client_secret = urllib.parse.unquote(authorization.password)
 		if oauthreq.client_secret is None:
 			return False
-		try:
-			oauthreq.client = OAuth2Client.from_id(oauthreq.client_id)
-		except KeyError:
+		oauthreq.client = OAuth2Client.query.filter_by(client_id=oauthreq.client_id).one_or_none()
+		if oauthreq.client is None:
 			return False
-		return secrets.compare_digest(oauthreq.client.client_secret, oauthreq.client_secret)
+		if not oauthreq.client.client_secret.verify(oauthreq.client_secret):
+			return False
+		if oauthreq.client.client_secret.needs_rehash:
+			oauthreq.client.client_secret = oauthreq.client_secret
+			db.session.commit()
+		return True
 
 	def get_default_redirect_uri(self, client_id, oauthreq, *args, **kwargs):
 		return oauthreq.client.default_redirect_uri
@@ -65,7 +66,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		return set(scopes).issubset({'profile'})
 
 	def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
-		grant = OAuth2Grant(user=oauthreq.user, client_id=client_id, code=code['code'],
+		grant = OAuth2Grant(user=oauthreq.user, client=oauthreq.client, code=code['code'],
 		                    redirect_uri=oauthreq.redirect_uri, scopes=oauthreq.scopes)
 		db.session.add(grant)
 		db.session.commit()
@@ -80,7 +81,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 			return False
 		grant_id, grant_code = code.split('-', 2)
 		oauthreq.grant = OAuth2Grant.query.get(grant_id)
-		if not oauthreq.grant or oauthreq.grant.client_id != client_id:
+		if not oauthreq.grant or oauthreq.grant.client != client:
 			return False
 		if not secrets.compare_digest(oauthreq.grant.code, grant_code):
 			return False
@@ -91,13 +92,13 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		return True
 
 	def invalidate_authorization_code(self, client_id, code, oauthreq, *args, **kwargs):
-		OAuth2Grant.query.filter_by(client_id=client_id, code=code).delete()
+		OAuth2Grant.query.filter_by(client=oauthreq.client, code=code).delete()
 		db.session.commit()
 
 	def save_bearer_token(self, token_data, oauthreq, *args, **kwargs):
 		tok = OAuth2Token(
 			user=oauthreq.user,
-			client_id=oauthreq.client.client_id,
+			client=oauthreq.client,
 			token_type=token_data['token_type'],
 			access_token=token_data['access_token'],
 			refresh_token=token_data['refresh_token'],
@@ -138,7 +139,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		oauthreq.user = tok.user
 		oauthreq.scopes = scopes
 		oauthreq.client = tok.client
-		oauthreq.client_id = tok.client_id
+		oauthreq.client_id = oauthreq.client.client_id
 		return True
 
 	# get_original_scopes/validate_refresh_token are only used for refreshing tokens. We don't implement the refresh endpoint.
@@ -157,7 +158,7 @@ def handle_oauth2error(error):
 @bp.route('/authorize', methods=['GET', 'POST'])
 def authorize():
 	scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers)
-	client = OAuth2Client.from_id(credentials['client_id'])
+	client = OAuth2Client.query.filter_by(client_id=credentials['client_id']).one()
 
 	if request.user:
 		credentials['user'] = request.user
@@ -168,7 +169,7 @@ def authorize():
 			flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay)))
 			return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
 		host_ratelimit.log()
-		initiation = OAuth2DeviceLoginInitiation(oauth2_client_id=client.client_id)
+		initiation = OAuth2DeviceLoginInitiation(client=client)
 		db.session.add(initiation)
 		try:
 			db.session.commit()
@@ -180,7 +181,7 @@ def authorize():
 		return redirect(url_for('session.devicelogin', ref=request.full_path))
 	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()
+		                                                         client=client).one_or_none()
 		confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation'])
 		del session['devicelogin_id']
 		del session['devicelogin_secret']
@@ -192,14 +193,14 @@ def authorize():
 		db.session.delete(initiation)
 		db.session.commit()
 	else:
-		flash(client.login_message)
+		flash(_('You need to login to access this service'))
 		return redirect(url_for('session.login', ref=request.full_path, 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.
 	if not client.access_allowed(credentials['user']):
-		abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.client_id))
+		abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.service.name))
 	session['oauth2-clients'] = session.get('oauth2-clients', [])
 	if client.client_id not in session['oauth2-clients']:
 		session['oauth2-clients'].append(client.client_id)
@@ -248,5 +249,5 @@ def logout():
 	if not request.values.get('client_ids'):
 		return secure_local_redirect(request.values.get('ref', '/'))
 	client_ids = request.values['client_ids'].split(',')
-	clients = [OAuth2Client.from_id(client_id) for client_id in client_ids]
+	clients = [OAuth2Client.query.filter_by(name=client_id).one() for client_id in client_ids]
 	return render_template('oauth2/logout.html', clients=clients)
diff --git a/uffd/selfservice/templates/selfservice/self.html b/uffd/selfservice/templates/selfservice/self.html
index d505d2bbcdc2fb56ffa0d807f4441391542b6520..0765b3c3668174e4ed949d715cf3a34ab48a2778 100644
--- a/uffd/selfservice/templates/selfservice/self.html
+++ b/uffd/selfservice/templates/selfservice/self.html
@@ -86,7 +86,7 @@
 		<h5>{{_("Roles")}}</h5>
 		<p>{{_("Aside from a set of base permissions, your roles determine the permissions of your account.")}}</p>
 		{% if config['SERVICES'] %}
-		<p>{{_("See <a href=\"%(services_url)s\">Services</a> for an overview of your current permissions.", services_url=url_for('services.index'))}}</p>
+		<p>{{_("See <a href=\"%(services_url)s\">Services</a> for an overview of your current permissions.", services_url=url_for('service.overview'))}}</p>
 		{% endif %}
 	</div>
 	<div class="col-12 col-md-7">
diff --git a/uffd/services/__init__.py b/uffd/service/__init__.py
similarity index 100%
rename from uffd/services/__init__.py
rename to uffd/service/__init__.py
diff --git a/uffd/services/views.py b/uffd/service/models.py
similarity index 64%
rename from uffd/services/views.py
rename to uffd/service/models.py
index 3423eba466fd77843e093ff77fd1d09abb84930f..7d3501e19a36091e80597fb4009661e5a07086dc 100644
--- a/uffd/services/views.py
+++ b/uffd/service/models.py
@@ -1,9 +1,37 @@
-from flask import Blueprint, render_template, current_app, abort, request
-from flask_babel import lazy_gettext, get_locale
+from flask import current_app
+from flask_babel import get_locale
+from sqlalchemy import Column, Integer, String, ForeignKey, Boolean
+from sqlalchemy.orm import relationship
 
-from uffd.navbar import register_navbar
+from uffd.database import db
 
-bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services')
+class Service(db.Model):
+	__tablename__ = 'service'
+	id = Column(Integer, primary_key=True, autoincrement=True)
+	name = Column(String(255), unique=True, nullable=False)
+
+	# If limit_access is False, all users have access and access_group is
+	# ignored. This attribute exists for legacy API and OAuth2 clients that
+	# were migrated from config definitions where a missing "required_group"
+	# parameter meant no access restrictions. Representing this state by
+	# setting access_group_id to NULL would lead to a bad/unintuitive ondelete
+	# behaviour.
+	limit_access = Column(Boolean(), default=True, nullable=False)
+	access_group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='SET NULL'), nullable=True)
+	access_group = relationship('Group')
+
+	oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan')
+	api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan')
+
+	def has_access(self, user):
+		return not self.limit_access or self.access_group in user.groups
+
+# The user-visible services show on the service overview page are read from
+# the SERVICES config key. It is planned to gradually extend the Service model
+# in order to finally replace the config-defined services.
+
+def get_language_specific(data, field_name, default =''):
+	return data.get(field_name + '_' + get_locale().language, data.get(field_name, default))
 
 # pylint: disable=too-many-branches
 def get_services(user=None):
@@ -72,24 +100,3 @@ def get_services(user=None):
 			service['links'].append(link_data)
 		services.append(service)
 	return services
-
-def get_language_specific(data, field_name, default =''):
-	return data.get(field_name + '_' + get_locale().language, data.get(field_name, default))
-
-def services_visible():
-	return len(get_services(request.user)) > 0
-
-@bp.route("/")
-@register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=services_visible)
-def index():
-	services = get_services(request.user)
-	if not current_app.config['SERVICES']:
-		abort(404)
-
-	banner = current_app.config.get('SERVICES_BANNER')
-
-	# Set the banner to None if it is not public and no user is logged in
-	if not (current_app.config["SERVICES_BANNER_PUBLIC"] or request.user):
-		banner = None
-
-	return render_template('services/overview.html', user=request.user, services=services, banner=banner)
diff --git a/uffd/service/templates/service/api.html b/uffd/service/templates/service/api.html
new file mode 100644
index 0000000000000000000000000000000000000000..9e0f7a8b3c70bf1997a36dac8d5555ecab4d4f7b
--- /dev/null
+++ b/uffd/service/templates/service/api.html
@@ -0,0 +1,51 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row">
+	<form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" class="form col-12 px-0">
+
+		<div class="form-group col">
+			<p class="text-right">
+				<a href="{{ url_for('service.show', id=service.id) }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
+				{% if client.id %}
+				<a class="btn btn-danger" href="{{ url_for('service.api_delete', service_id=service.id, id=client.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'>
+					<i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}}
+				</a>
+				{% endif %}
+				<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button>
+			</p>
+		</div>
+
+		<div class="form-group col">
+			<label for="client-auth-username">{{ _('Authentication Username') }}</label>
+			<input type="text" class="form-control" id="client-auth-username" name="auth_username" value="{{ client.auth_username or '' }}" required>
+		</div>
+
+		<div class="form-group col">
+			<label for="client-auth-password">{{ _('Authentication Password') }}</label>
+			{% if client.id %}
+			<input type="password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●">
+			{% else %}
+			<input type="password" class="form-control" id="client-auth-password" name="auth_password" required>
+			{% endif %}
+		</div>
+
+		<div class="form-group col">
+			<h6>{{ _('Permissions') }}</h6>
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="client-perm-users" name="perm_users" value="1" aria-label="enabled" {{ 'checked' if client.perm_users }}>
+				<label class="form-check-label" for="client-perm-users"><b>users</b>: {{_('Access user and group data')}}</label>
+			</div>
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="client-perm-checkpassword" name="perm_checkpassword" value="1" aria-label="enabled" {{ 'checked' if client.perm_checkpassword }}>
+				<label class="form-check-label" for="client-perm-checkpassword"><b>checkpassword</b>: {{_('Verify user passwords')}}</label>
+			</div>
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="client-perm-mail-aliases" name="perm_mail_aliases" value="1" aria-label="enabled" {{ 'checked' if client.perm_mail_aliases }}>
+				<label class="form-check-label" for="client-perm-mail-aliases"><b>mail_aliases</b>: {{_('Access mail aliases')}}</label>
+			</div>
+		</div>
+
+	</form>
+</div>
+{% endblock %}
diff --git a/uffd/service/templates/service/index.html b/uffd/service/templates/service/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..97e569fd44a94496b4849f977045b7903436bae6
--- /dev/null
+++ b/uffd/service/templates/service/index.html
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row">
+	<div class="col">
+		<p class="text-right">
+			<a class="btn btn-primary" href="{{ url_for('service.show') }}">
+				<i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}}
+			</a>
+		</p>
+		<table class="table table-striped table-sm">
+			<thead>
+				<tr>
+					<th scope="col">{{ _('Name') }}</th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for service in services|sort(attribute="name") %}
+				<tr>
+					<td>
+						<a href="{{ url_for("service.show", id=service.id) }}">
+							{{ service.name }}
+						</a>
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+</div>
+{% endblock %}
diff --git a/uffd/service/templates/service/oauth2.html b/uffd/service/templates/service/oauth2.html
new file mode 100644
index 0000000000000000000000000000000000000000..613fb82122267c6a1a4fd5d56a4580d7f9b05c87
--- /dev/null
+++ b/uffd/service/templates/service/oauth2.html
@@ -0,0 +1,55 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row">
+	<form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" class="form col-12 px-0">
+
+		<div class="form-group col">
+			<p class="text-right">
+				<a href="{{ url_for('service.show', id=service.id) }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
+				{% if client.db_id %}
+				<a class="btn btn-danger" href="{{ url_for('service.oauth2_delete', service_id=service.id, db_id=client.db_id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'>
+					<i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}}
+				</a>
+				{% endif %}
+				<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button>
+			</p>
+		</div>
+
+		<div class="form-group col">
+			<label for="client-client-id">{{ _('Client ID') }}</label>
+			<input type="text" class="form-control" id="client-client-id" name="client_id" value="{{ client.client_id or '' }}" required>
+		</div>
+
+		<div class="form-group col">
+			<label for="client-client-secret">{{ _('Client Secret') }}</label>
+			{% if client.db_id %}
+			<input type="password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●">
+			{% else %}
+			<input type="password" class="form-control" id="client-client-secret" name="client_secret" required>
+			{% endif %}
+		</div>
+
+		<div class="form-group col">
+			<label for="client-redirect-uris">{{ _('Redirect URIs') }}</label>
+			<textarea rows="3" class="form-control" id="client-redirect-uris" name="redirect_uris">{{ client.redirect_uris|join('\n') }}</textarea>
+			<small class="form-text text-muted">
+				{{ _('One URI per line') }}
+			</small>
+		</div>
+
+		<div class="form-group col">
+			<label for="client-logout-uris">{{ _('Logout URIs') }}</label>
+			<textarea rows="3" class="form-control" id="client-logout-uris" name="logout_uris" placeholder="GET https://example.com/logout">
+{%- for logout_uri in client.logout_uris %}
+{{ logout_uri.method }} {{ logout_uri.uri }}
+{%- endfor %}
+</textarea>
+			<small class="form-text text-muted">
+				{{ _('One URI per line, prefixed with space-separated method (GET/POST)') }}
+			</small>
+		</div>
+
+	</form>
+</div>
+{% endblock %}
diff --git a/uffd/services/templates/services/overview.html b/uffd/service/templates/service/overview.html
similarity index 93%
rename from uffd/services/templates/services/overview.html
rename to uffd/service/templates/service/overview.html
index 0ccdb6f2b27d5690080899125f86ae02541b42a3..88b16c128d363a62fb6f778c192d535d5c34018b 100644
--- a/uffd/services/templates/services/overview.html
+++ b/uffd/service/templates/service/overview.html
@@ -59,6 +59,12 @@
   </div>
 {% endmacro %}
 
+{% if request.user and request.user.is_in_group(config['ACL_ADMIN_GROUP']) %}
+<div class="text-right">
+	<a href="{{ url_for('service.index') }}" class="btn btn-primary">{{ _('Manage OAuth2 and API clients') }}</a>
+</div>
+{% endif %}
+
 <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 mt-2">
 	{% for service in services if service.has_access %}
 		{{ service_card(service) }}
diff --git a/uffd/service/templates/service/show.html b/uffd/service/templates/service/show.html
new file mode 100644
index 0000000000000000000000000000000000000000..0bb892c258ea6c6ca36fa066bef3e34208dca675
--- /dev/null
+++ b/uffd/service/templates/service/show.html
@@ -0,0 +1,101 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+<div class="row">
+
+	<form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" class="form col-12 px-0">
+		<div class="form-group col">
+			<p class="text-right">
+				<a href="{{ url_for('service.index') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
+				{% if service.id %}
+				<a class="btn btn-danger" href="{{ url_for('service.delete', id=service.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'>
+					<i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}}
+				</a>
+				{% endif %}
+				<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button>
+			</p>
+		</div>
+		<div class="form-group col">
+			<label for="service-name">{{ _('Name') }}</label>
+			<input type="text" class="form-control" id="service-name" name="name" value="{{ service.name or '' }}" required>
+		</div>
+		<div class="form-group col">
+			<label for="moderator-group">{{ _('Access Restriction') }}</label>
+			<select class="form-control" id="access-group" name="access-group">
+				<option value="" class="text-muted">{{ _('No user has access') }}</option>
+				<option value="all" class="text-muted" {{ 'selected' if not service.limit_access }}>{{ _('All users have access (legacy)') }}</option>
+				{% for group in all_groups %}
+				<option value="{{ group.id }}" {{ 'selected' if group == service.access_group and service.limit_access }}>{{ _('Members of group "%(group_name)s" have access', group_name=group.name) }}</option>
+				{% endfor %}
+			</select>
+		</div>
+	</form>
+
+	{% if service.id %}
+	<div class="col-12">
+		<hr>
+		<h5>OAuth2 Clients</h5>
+		<p class="text-right">
+			<a class="btn btn-primary" href="{{ url_for('service.oauth2_show', service_id=service.id) }}">
+				<i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}}
+			</a>
+		</p>
+		<table class="table table-striped table-sm">
+			<thead>
+				<tr>
+					<th scope="col">{{ _('Client ID') }}</th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for client in service.oauth2_clients|sort(attribute='client_id') %}
+				<tr>
+					<td>
+						<a href="{{ url_for("service.oauth2_show", service_id=service.id, db_id=client.db_id) }}">
+							{{ client.client_id }}
+						</a>
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+
+	<div class="col-12">
+		<hr>
+		<h5>API Clients</h5>
+		<p class="text-right">
+			<a class="btn btn-primary" href="{{ url_for('service.api_show', service_id=service.id) }}">
+				<i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}}
+			</a>
+		</p>
+		<table class="table table-striped table-sm">
+			<thead>
+				<tr>
+					<th scope="col">{{ _('Name') }}</th>
+					<th scope="col">{{ _('Permissions') }}</th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for client in service.api_clients|sort(attribute='auth_username') %}
+				<tr>
+					<td>
+						<a href="{{ url_for("service.api_show", service_id=service.id, id=client.id) }}">
+							{{ client.auth_username }}
+						</a>
+					</td>
+					<td>
+						{% for perm in ['users', 'checkpassword', 'mail_aliases'] if client.has_permission(perm) %}
+						{{ perm }}{{ ',' if not loop.last }}
+						{% endfor %}
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+	{% endif %}
+
+</div>
+
+{% endblock %}
diff --git a/uffd/service/views.py b/uffd/service/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f5623cae17965d3845fe519280ce3c51d22d73f
--- /dev/null
+++ b/uffd/service/views.py
@@ -0,0 +1,161 @@
+from flask import Blueprint, render_template, request, url_for, redirect, current_app, abort
+from flask_babel import lazy_gettext
+
+from uffd.navbar import register_navbar
+from uffd.csrf import csrf_protect
+from uffd.session import login_required
+from uffd.service.models import Service, get_services
+from uffd.user.models import Group
+from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI
+from uffd.api.models import APIClient
+from uffd.database import db
+
+bp = Blueprint('service', __name__, template_folder='templates')
+
+def service_admin_acl():
+	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+
+def service_overview_acl():
+	return len(get_services(request.user)) > 0 or service_admin_acl()
+
+@bp.route('/services/')
+@register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=service_overview_acl)
+def overview():
+	services = get_services(request.user)
+	if not service_overview_acl():
+		abort(404)
+	banner = current_app.config.get('SERVICES_BANNER')
+	# Set the banner to None if it is not public and no user is logged in
+	if not (current_app.config['SERVICES_BANNER_PUBLIC'] or request.user):
+		banner = None
+	return render_template('service/overview.html', user=request.user, services=services, banner=banner)
+
+@bp.route('/service/admin')
+@login_required(service_admin_acl)
+def index():
+	return render_template('service/index.html', services=Service.query.all())
+
+@bp.route('/service/new')
+@bp.route('/service/<int:id>')
+@login_required(service_admin_acl)
+def show(id=None):
+	service = Service() if id is None else Service.query.get_or_404(id)
+	all_groups = Group.query.all()
+	return render_template('service/show.html', service=service, all_groups=all_groups)
+
+@bp.route('/service/new', methods=['POST'])
+@bp.route('/service/<int:id>', methods=['POST'])
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def edit_submit(id=None):
+	if id is None:
+		service = Service()
+		db.session.add(service)
+	else:
+		service = Service.query.get_or_404(id)
+	service.name = request.form['name']
+	if not request.form['access-group']:
+		service.limit_access = True
+		service.access_group = None
+	elif request.form['access-group'] == 'all':
+		service.limit_access = False
+		service.access_group = None
+	else:
+		service.limit_access = True
+		service.access_group = Group.query.get(request.form['access-group'])
+	db.session.commit()
+	return redirect(url_for('service.show', id=service.id))
+
+@bp.route('/service/<int:id>/delete')
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def delete(id):
+	service = Service.query.get_or_404(id)
+	db.session.delete(service)
+	db.session.commit()
+	return redirect(url_for('service.index'))
+
+@bp.route('/service/<int:service_id>/oauth2/new')
+@bp.route('/service/<int:service_id>/oauth2/<int:db_id>')
+@login_required(service_admin_acl)
+def oauth2_show(service_id, db_id=None):
+	service = Service.query.get_or_404(service_id)
+	client = OAuth2Client() if db_id is None else OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
+	return render_template('service/oauth2.html', service=service, client=client)
+
+@bp.route('/service/<int:service_id>/oauth2/new', methods=['POST'])
+@bp.route('/service/<int:service_id>/oauth2/<int:db_id>', methods=['POST'])
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def oauth2_submit(service_id, db_id=None):
+	service = Service.query.get_or_404(service_id)
+	if db_id is None:
+		client = OAuth2Client(service=service)
+		db.session.add(client)
+	else:
+		client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
+	client.client_id = request.form['client_id']
+	if request.form['client_secret']:
+		client.client_secret = request.form['client_secret']
+	if not client.client_secret:
+		abort(400)
+	client.redirect_uris = request.form['redirect_uris'].strip().split('\n')
+	client.logout_uris = []
+	for line in request.form['logout_uris'].split('\n'):
+		line = line.strip()
+		if not line:
+			continue
+		method, uri = line.split(' ', 2)
+		client.logout_uris.append(OAuth2LogoutURI(method=method, uri=uri))
+	db.session.commit()
+	return redirect(url_for('service.show', id=service.id))
+
+@bp.route('/service/<int:service_id>/oauth2/<int:db_id>/delete')
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def oauth2_delete(service_id, db_id=None):
+	service = Service.query.get_or_404(service_id)
+	client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404()
+	db.session.delete(client)
+	db.session.commit()
+	return redirect(url_for('service.show', id=service.id))
+
+@bp.route('/service/<int:service_id>/api/new')
+@bp.route('/service/<int:service_id>/api/<int:id>')
+@login_required(service_admin_acl)
+def api_show(service_id, id=None):
+	service = Service.query.get_or_404(service_id)
+	client = APIClient() if id is None else APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
+	return render_template('service/api.html', service=service, client=client)
+
+@bp.route('/service/<int:service_id>/api/new', methods=['POST'])
+@bp.route('/service/<int:service_id>/api/<int:id>', methods=['POST'])
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def api_submit(service_id, id=None):
+	service = Service.query.get_or_404(service_id)
+	if id is None:
+		client = APIClient(service=service)
+		db.session.add(client)
+	else:
+		client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
+	client.auth_username = request.form['auth_username']
+	if request.form['auth_password']:
+		client.auth_password = request.form['auth_password']
+	if not client.auth_password:
+		abort(400)
+	client.perm_users = request.form.get('perm_users') == '1'
+	client.perm_checkpassword = request.form.get('perm_checkpassword') == '1'
+	client.perm_mail_aliases = request.form.get('perm_mail_aliases') == '1'
+	db.session.commit()
+	return redirect(url_for('service.show', id=service.id))
+
+@bp.route('/service/<int:service_id>/api/<int:id>/delete')
+@csrf_protect(blueprint=bp)
+@login_required(service_admin_acl)
+def api_delete(service_id, id=None):
+	service = Service.query.get_or_404(service_id)
+	client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404()
+	db.session.delete(client)
+	db.session.commit()
+	return redirect(url_for('service.show', id=service.id))
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index fae15547dea316a68d4e4a766cc5cbe185c27655..a5e0810dc913757f660c8c17a94cff39d7f87cae 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 aac46a197d92b30b2b7f1834a1f40315ff96e8a4..91aa82472db8a8e69afeaaa2a26101cffac18074 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-15 23:23+0100\n"
+"POT-Creation-Date: 2022-02-18 04:41+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -97,6 +97,9 @@ msgstr "Mailversand fehlgeschlagen"
 
 #: uffd/invite/templates/invite/list.html:6
 #: uffd/mail/templates/mail/list.html:8 uffd/role/templates/role/list.html:8
+#: uffd/service/templates/service/index.html:8
+#: uffd/service/templates/service/show.html:41
+#: uffd/service/templates/service/show.html:69
 #: uffd/user/templates/group/list.html:8 uffd/user/templates/user/list.html:8
 msgid "New"
 msgstr "Neu"
@@ -110,6 +113,8 @@ msgid "Created by"
 msgstr "Erstellt durch"
 
 #: uffd/invite/templates/invite/list.html:14
+#: uffd/service/templates/service/api.html:34
+#: uffd/service/templates/service/show.html:76
 msgid "Permissions"
 msgstr "Berechtigungen"
 
@@ -269,6 +274,9 @@ msgstr "Enthaltene Rollen"
 #: uffd/rolemod/templates/rolemod/list.html:9
 #: uffd/rolemod/templates/rolemod/show.html:44
 #: uffd/selfservice/templates/selfservice/self.html:97
+#: uffd/service/templates/service/index.html:14
+#: uffd/service/templates/service/show.html:20
+#: uffd/service/templates/service/show.html:75
 #: uffd/user/templates/group/list.html:15
 #: uffd/user/templates/group/show.html:26
 #: uffd/user/templates/user/show.html:106
@@ -296,6 +304,9 @@ msgstr "Link erstellen"
 #: uffd/mail/templates/mail/show.html:28 uffd/mfa/templates/mfa/auth.html:33
 #: uffd/role/templates/role/show.html:14
 #: uffd/rolemod/templates/rolemod/show.html:9
+#: uffd/service/templates/service/api.html:9
+#: uffd/service/templates/service/oauth2.html:9
+#: uffd/service/templates/service/show.html:10
 #: uffd/session/templates/session/deviceauth.html:39
 #: uffd/session/templates/session/deviceauth.html:49
 #: uffd/session/templates/session/devicelogin.html:29
@@ -354,16 +365,16 @@ msgstr "Anmelden und die Rollen zu deinem Account hinzufügen"
 msgid "Forwardings"
 msgstr "Weiterleitungen"
 
-#: uffd/mail/views.py:46
+#: uffd/mail/views.py:47
 #, python-format
 msgid "Invalid receive address: %(mail_address)s"
 msgstr "Ungültige Empfangsadresse: %(mail_address)s"
 
-#: uffd/mail/views.py:50
+#: uffd/mail/views.py:51
 msgid "Mail mapping updated."
 msgstr "Mailweiterleitung geändert."
 
-#: uffd/mail/views.py:59
+#: uffd/mail/views.py:60
 msgid "Deleted mail mapping."
 msgstr "Mailweiterleitung gelöscht."
 
@@ -389,6 +400,9 @@ msgstr "Eine Adresse pro Zeile"
 
 #: uffd/mail/templates/mail/show.html:27 uffd/role/templates/role/show.html:13
 #: uffd/rolemod/templates/rolemod/show.html:8
+#: uffd/service/templates/service/api.html:15
+#: uffd/service/templates/service/oauth2.html:15
+#: uffd/service/templates/service/show.html:16
 #: uffd/user/templates/group/show.html:8 uffd/user/templates/user/show.html:7
 msgid "Save"
 msgstr "Speichern"
@@ -396,6 +410,9 @@ msgstr "Speichern"
 #: uffd/mail/templates/mail/show.html:30 uffd/mail/templates/mail/show.html:32
 #: uffd/mfa/templates/mfa/setup.html:117 uffd/mfa/templates/mfa/setup.html:179
 #: uffd/role/templates/role/show.html:21 uffd/role/templates/role/show.html:24
+#: uffd/service/templates/service/api.html:12
+#: uffd/service/templates/service/oauth2.html:12
+#: uffd/service/templates/service/show.html:13
 #: uffd/user/templates/group/show.html:11
 #: uffd/user/templates/group/show.html:13 uffd/user/templates/user/show.html:10
 #: uffd/user/templates/user/show.html:12
@@ -721,10 +738,6 @@ msgstr "Sekunden"
 msgid "Verify and complete setup"
 msgstr "Verifiziere und beende das Setup"
 
-#: 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:169 uffd/selfservice/views.py:71
 #: uffd/session/views.py:73
 #, python-format
@@ -743,6 +756,10 @@ msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
 msgid "Device login failed"
 msgstr "Gerätelogin fehlgeschlagen"
 
+#: uffd/oauth2/views.py:196
+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:203
 #, python-format
 msgid ""
@@ -830,6 +847,9 @@ msgstr "Als Default setzen"
 
 #: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21
 #: uffd/selfservice/templates/selfservice/self.html:112
+#: uffd/service/templates/service/api.html:11
+#: uffd/service/templates/service/oauth2.html:11
+#: uffd/service/templates/service/show.html:12
 #: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:10
 #: uffd/user/templates/user/show.html:94
 msgid "Are you sure?"
@@ -1175,11 +1195,58 @@ msgstr "Passwort zurücksetzen"
 msgid "Set password"
 msgstr "Passwort setzen"
 
-#: uffd/services/views.py:83
+#: uffd/service/views.py:22
 msgid "Services"
 msgstr "Dienste"
 
-#: uffd/services/templates/services/overview.html:8
+#: uffd/service/templates/service/api.html:20
+msgid "Authentication Username"
+msgstr "Authentifikations-Name"
+
+#: uffd/service/templates/service/api.html:25
+msgid "Authentication Password"
+msgstr "Authentifikations-Passwort"
+
+#: uffd/service/templates/service/api.html:37
+msgid "Access user and group data"
+msgstr "Zugriff auf Account- und Gruppen-Daten"
+
+#: uffd/service/templates/service/api.html:41
+msgid "Verify user passwords"
+msgstr "Passwörter von Nutzeraccounts verifizieren"
+
+#: uffd/service/templates/service/api.html:45
+msgid "Access mail aliases"
+msgstr "Zugriff auf Mail-Weiterleitungen"
+
+#: uffd/service/templates/service/oauth2.html:20
+#: uffd/service/templates/service/show.html:47
+msgid "Client ID"
+msgstr "Client-ID"
+
+#: uffd/service/templates/service/oauth2.html:25
+msgid "Client Secret"
+msgstr "Client-Secret"
+
+#: uffd/service/templates/service/oauth2.html:34
+msgid "Redirect URIs"
+msgstr "Redirect-URIs"
+
+#: uffd/service/templates/service/oauth2.html:37
+msgid "One URI per line"
+msgstr "Eine URI pro Zeile"
+
+#: uffd/service/templates/service/oauth2.html:42
+msgid "Logout URIs"
+msgstr "Abmelde-URIs"
+
+#: uffd/service/templates/service/oauth2.html:49
+msgid "One URI per line, prefixed with space-separated method (GET/POST)"
+msgstr ""
+"Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-"
+"Methode (GET/POST)"
+
+#: uffd/service/templates/service/overview.html:8
 msgid ""
 "Some services may not be publicly listed! Log in to see all services you "
 "have access to."
@@ -1187,15 +1254,36 @@ msgstr ""
 "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an"
 " um alle Dienste zu sehen, auf die du Zugriff hast."
 
-#: uffd/services/templates/services/overview.html:44
+#: uffd/service/templates/service/overview.html:44
 msgid "No access"
 msgstr "Kein Zugriff"
 
-#: uffd/services/templates/services/overview.html:78
+#: uffd/service/templates/service/overview.html:64
+msgid "Manage OAuth2 and API clients"
+msgstr "OAuth2- und API-Clients verwalten"
+
+#: uffd/service/templates/service/overview.html:84
 #: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79
 msgid "Close"
 msgstr "Schließen"
 
+#: uffd/service/templates/service/show.html:24
+msgid "Access Restriction"
+msgstr "Zugriffsbeschränkungen"
+
+#: uffd/service/templates/service/show.html:26
+msgid "No user has access"
+msgstr "Kein Account hat Zugriff"
+
+#: uffd/service/templates/service/show.html:27
+msgid "All users have access (legacy)"
+msgstr "Alle Account haben Zugriff (veraltet)"
+
+#: uffd/service/templates/service/show.html:29
+#, python-format
+msgid "Members of group \"%(group_name)s\" have access"
+msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff"
+
 #: uffd/session/views.py:71
 #, python-format
 msgid ""
@@ -1445,7 +1533,7 @@ msgstr "Ändern"
 msgid "About uffd"
 msgstr "Über uffd"
 
-#: uffd/user/models.py:48
+#: uffd/user/models.py:31
 #, python-format
 msgid ""
 "At least %(minlen)d and at most %(maxlen)d characters. Only letters, "