diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f2c419e6204d81899cd66f0dfaa48aee81d3d44b..df00c3fac0cf86203e80478ed1b43b9fc52dce1b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_,
 and this project adheres to `Semantic Versioning`_.
 
+Unreleased
+----------
+
+Added
+~~~~~
+
+* Allow apps to dynamically generate OAuth scopes
+
+Removed
+~~~~~~~
+
+* `OAUTH2_SCOPES` setting in apps is not supported anymore. Use `get_all_scopes` method
+  on `AppConfig` class instead.
+
+
 `2.0rc4`_ - 2021-08-01
 ----------------------
 
diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index 9679c309ed9e6280495b420651e6c17fe56d3daf..d9a3f38fa379ddebb10073f7b5afd53301984da1 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -2,6 +2,7 @@ from typing import Any, Optional
 
 import django.apps
 from django.apps import apps
+from django.conf import settings
 from django.http import HttpRequest
 from django.utils.module_loading import autodiscover_modules
 
@@ -136,3 +137,18 @@ class CoreConfig(AppConfig):
         if has_person(user):
             # Save the associated person to pick up defaults
             user.person.save()
+
+    def get_all_scopes(self) -> dict[str, str]:
+        scopes = {
+            "read": "Read anything the resource owner can read",
+            "write": "Write anything the resource owner can write",
+        }
+        if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
+            scopes |= {
+                "openid": _("OpenID Connect scope"),
+                "profile": _("Given name, family name, link to profile and picture if existing."),
+                "address": _("Full home postal address"),
+                "email": _("Email address"),
+                "phone": _("Home and mobile phone"),
+            }
+        return scopes
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index faa5bcf1da53c97bd878c33bced3e9e2a6e384a8..fbd436ae1d69c3d705a9a3ec9896538974f02eb6 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -324,13 +324,7 @@ ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", Tru
 
 # Configuration for OAuth2 provider
 
-OAUTH2_PROVIDER = {
-    "SCOPES": {
-        "read": "Read anything the resource owner can read",
-        "write": "Write anything the resource owner can write",
-    }
-}
-merge_app_settings("OAUTH2_SCOPES", OAUTH2_PROVIDER["SCOPES"], True)
+OAUTH2_PROVIDER = {"SCOPES_BACKEND_CLASS": "aleksis.core.util.auth_helpers.AppScopes"}
 
 if _settings.get("oauth2.oidc.enabled", False):
     with open(_settings.get("oauth2.oidc.rsa_key", "/etc/aleksis/oidc.pem"), "r") as f:
diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py
index 5f597af29b10363ca003622e347bc76380864efd..5c31ae54acdb711d42084ac9ee4fd52d0335729a 100644
--- a/aleksis/core/util/apps.py
+++ b/aleksis/core/util/apps.py
@@ -1,5 +1,5 @@
 from importlib import metadata
-from typing import Any, Optional, Sequence
+from typing import TYPE_CHECKING, Any, Optional, Sequence
 
 import django.apps
 from django.contrib.auth.signals import user_logged_in, user_logged_out
@@ -12,6 +12,9 @@ from spdx_license_list import LICENSES
 
 from .core_helpers import copyright_years
 
+if TYPE_CHECKING:
+    from oauth2_provider.models import AbstractApplication
+
 
 class AppConfig(django.apps.AppConfig):
     """An extended version of DJango's AppConfig container."""
@@ -214,6 +217,30 @@ class AppConfig(django.apps.AppConfig):
         """
         pass
 
+    def get_all_scopes(self) -> dict[str, str]:
+        """Return all OAuth scopes and their descriptions for this app."""
+        return {}
+
+    def get_available_scopes(
+        self,
+        application: Optional["AbstractApplication"] = None,
+        request: Optional[HttpRequest] = None,
+        *args,
+        **kwargs,
+    ) -> list[str]:
+        """Return a list of all OAuth scopes available to the request and application."""
+        return list(self.get_all_scopes().keys())
+
+    def get_default_scopes(
+        self,
+        application: Optional["AbstractApplication"] = None,
+        request: Optional[HttpRequest] = None,
+        *args,
+        **kwargs,
+    ) -> list[str]:
+        """Return a list of all OAuth scopes to always include for this request and application."""
+        return []
+
     def _maintain_default_data(self):
         from django.contrib.auth.models import Permission
         from django.contrib.contenttypes.models import ContentType
diff --git a/aleksis/core/util/auth_helpers.py b/aleksis/core/util/auth_helpers.py
index ed7bced95ac2a83c32d2919ad484204448f71e6f..8caea1659fd821f10229e5339adffd5a56659c99 100644
--- a/aleksis/core/util/auth_helpers.py
+++ b/aleksis/core/util/auth_helpers.py
@@ -1,12 +1,17 @@
 """Helpers/overrides for django-allauth."""
 
+from typing import Optional
+
 from django.conf import settings
 from django.http import HttpRequest
 
 from allauth.account.adapter import DefaultAccountAdapter
 from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
+from oauth2_provider.models import AbstractApplication
 from oauth2_provider.oauth2_validators import OAuth2Validator
+from oauth2_provider.scopes import BaseScopes
 
+from .apps import AppConfig
 from .core_helpers import get_site_preferences, has_person
 
 
@@ -73,3 +78,41 @@ class CustomOAuth2Validator(OAuth2Validator):
             }
 
         return claims
+
+
+class AppScopes(BaseScopes):
+    """Scopes backend for django-oauth-toolkit gathering scopes from apps.
+
+    Will call the respective method on all known AlekSIS app configs and
+    join the results.
+    """
+
+    def get_all_scopes(self) -> dict[str, str]:
+        scopes = {}
+        for app in AppConfig.__subclasses__():
+            scopes |= app.get_all_scopes()
+        return scopes
+
+    def get_available_scopes(
+        self,
+        application: Optional[AbstractApplication] = None,
+        request: Optional[HttpRequest] = None,
+        *args,
+        **kwargs
+    ) -> list[str]:
+        scopes = []
+        for app in AppConfig.__subclasses__():
+            scopes += app.get_available_scopes()
+        return scopes
+
+    def get_default_scopes(
+        self,
+        application: Optional[AbstractApplication] = None,
+        request: Optional[HttpRequest] = None,
+        *args,
+        **kwargs
+    ) -> list[str]:
+        scopes = []
+        for app in AppConfig.__subclasses__():
+            scopes += app.get_default_scopes()
+        return scopes