diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py
index ae51f4bcfddec26f5b31af1fb79628ac6812476f..45de07f84a8a9fa4a90a621be307e7cf1e730cd9 100644
--- a/aleksis/core/admin.py
+++ b/aleksis/core/admin.py
@@ -2,6 +2,7 @@
 
 from django.contrib import admin
 
+from guardian.admin import GuardedModelAdminMixin
 from reversion.admin import VersionAdmin
 
 from .mixins import BaseModelAdmin
@@ -16,8 +17,6 @@ from .models import (
     Person,
 )
 
-admin.site.register(Person, VersionAdmin)
-admin.site.register(Group, VersionAdmin)
 admin.site.register(Activity, VersionAdmin)
 admin.site.register(Notification, VersionAdmin)
 admin.site.register(CustomMenuItem, VersionAdmin)
@@ -33,5 +32,11 @@ class AnnouncementAdmin(BaseModelAdmin, VersionAdmin):
     ]
 
 
+class GuardedVersionAdmin(GuardedModelAdminMixin, VersionAdmin):
+    pass
+
+
 admin.site.register(Announcement, AnnouncementAdmin)
 admin.site.register(DataCheckResult)
+admin.site.register(Person, GuardedVersionAdmin)
+admin.site.register(Group, GuardedVersionAdmin)
diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index 75feb14c3ed9e6d717d1bbd89bf0ef87070d34b3..3d3f86933acb3b86e87c644bfc92db37832c27cb 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -2,8 +2,6 @@ from typing import Any, List, Optional, Tuple
 
 import django.apps
 from django.apps import apps
-from django.conf import settings
-from django.db import OperationalError, ProgrammingError
 from django.http import HttpRequest
 from django.utils.module_loading import autodiscover_modules
 
@@ -16,7 +14,7 @@ from .registries import (
     site_preferences_registry,
 )
 from .util.apps import AppConfig
-from .util.core_helpers import get_site_preferences, has_person
+from .util.core_helpers import has_person
 from .util.sass_helpers import clean_scss
 
 
@@ -52,8 +50,6 @@ class CoreConfig(AppConfig):
         preference_models.register(personpreferencemodel, person_preferences_registry)
         preference_models.register(grouppreferencemodel, group_preferences_registry)
 
-        self._refresh_authentication_backends()
-
         self._load_data_checks()
 
         from .health_checks import DataChecksHealthCheckBackend
@@ -70,24 +66,6 @@ class CoreConfig(AppConfig):
             data_checks += getattr(model, "data_checks", [])
         DataCheckRegistry.data_checks = data_checks
 
-    @classmethod
-    def _refresh_authentication_backends(cls):
-        """Refresh config list of enabled authentication backends."""
-        from .preferences import AuthenticationBackends  # noqa
-
-        idx = settings.AUTHENTICATION_BACKENDS.index("django.contrib.auth.backends.ModelBackend")
-
-        try:
-            # Don't set array directly in order to keep object reference
-            settings._wrapped.AUTHENTICATION_BACKENDS.clear()
-            settings._wrapped.AUTHENTICATION_BACKENDS += settings.ORIGINAL_AUTHENTICATION_BACKENDS
-
-            for backend in get_site_preferences()["auth__backends"]:
-                settings._wrapped.AUTHENTICATION_BACKENDS.insert(idx, backend)
-                idx += 1
-        except (ProgrammingError, OperationalError):
-            pass
-
     def preference_updated(
         self,
         sender: Any,
@@ -97,9 +75,6 @@ class CoreConfig(AppConfig):
         new_value: Optional[Any] = None,
         **kwargs,
     ) -> None:
-        if section == "auth" and name == "backends":
-            self._refresh_authentication_backends()
-
         if section == "theme":
             if name in ("primary", "secondary"):
                 clean_scss()
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 74355878aeac456d32b5e9f233e1d0d055210226..78c95d604960ff6413bcae7a1b625110c9052dda 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -1,6 +1,8 @@
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 
+from .util.core_helpers import unread_notifications_badge
+
 MENUS = {
     "NAV_MENU_CORE": [
         {
@@ -24,6 +26,15 @@ MENUS = {
             "icon": "home",
             "validators": ["menu_generator.validators.is_authenticated"],
         },
+        {
+            "name": _("Notifications"),
+            "url": "notifications",
+            "icon": "notifications",
+            "badge": unread_notifications_badge,
+            "validators": [
+                ("aleksis.core.util.predicates.permission_validator", "core.view_notifications",),
+            ],
+        },
         {
             "name": _("Account"),
             "url": "#",
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 8927844a47da2f30e030a9b0815e82539940d8ba..85b16b1e23e597efb14a91803f7515cbdc2195ac 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -245,6 +245,16 @@ class Person(ExtensibleModel):
             )
         ]
 
+    @property
+    def unread_notifications(self) -> QuerySet:
+        """Get all unread notifications for this person."""
+        return self.notifications.filter(read=False)
+
+    @property
+    def unread_notifications_count(self) -> int:
+        """Return the count of unread notifications for this person."""
+        return self.unread_notifications.count()
+
     def save(self, *args, **kwargs):
         super().save(*args, **kwargs)
 
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index 1b1b48c77b7cfda10b37f086f334f1d9aa5cdd67..03542dbefe797ce5cdadc71b1078f258e3eed069 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -172,6 +172,24 @@ class PrimaryGroupField(ChoicePreference):
         return Person.syncable_fields_choices()
 
 
+@site_preferences_registry.register
+class AutoCreatePerson(BooleanPreference):
+    section = account
+    name = "auto_create_person"
+    default = False
+    required = False
+    verbose_name = _("Automatically create new persons for new users")
+
+
+@site_preferences_registry.register
+class AutoLinkPerson(BooleanPreference):
+    section = account
+    name = "auto_link_person"
+    default = False
+    required = False
+    verbose_name = _("Automatically link existing persons to new users by their e-mail address")
+
+
 @site_preferences_registry.register
 class SchoolName(StringPreference):
     section = school
@@ -190,18 +208,6 @@ class SchoolNameOfficial(StringPreference):
     verbose_name = _("Official name of the school, e.g. as given by supervisory authority")
 
 
-@site_preferences_registry.register
-class AuthenticationBackends(MultipleChoicePreference):
-    section = auth
-    name = "backends"
-    default = None
-    verbose_name = _("Enabled custom authentication backends")
-    field_attribute = {"initial": []}
-
-    def get_choices(self):
-        return [(b, b) for b in settings.CUSTOM_AUTHENTICATION_BACKENDS]
-
-
 @site_preferences_registry.register
 class SignupEnabled(BooleanPreference):
     section = auth
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index e72a74e86f74545e80de2971856e0967a0a06ef2..225024084edcf17f89e45a479fd1fec375df4d95 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -17,6 +17,9 @@ rules.add_perm("core", rules.always_allow)
 # View dashboard
 rules.add_perm("core.view_dashboard", has_person)
 
+# View notifications
+rules.add_perm("core.view_notifications", has_person)
+
 # Use search
 search_predicate = has_person & has_global_perm("core.search")
 rules.add_perm("core.search", search_predicate)
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 8f999cf49c39d8b316aa33a669a4f0de73097a74..1be207a2cb2b854d99cdb3603f35db54427aa3d8 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -42,6 +42,7 @@ DEBUG_TOOLBAR_CONFIG = {
     "SHOW_COLLAPSED": True,
     "JQUERY_URL": "",
     "SHOW_TOOLBAR_CALLBACK": "aleksis.core.util.core_helpers.dt_show_toolbar",
+    "DISABLE_PANELS": {},
 }
 
 DEBUG_TOOLBAR_PANELS = [
@@ -785,6 +786,4 @@ HEALTH_CHECK = {
     "MEMORY_MIN": _settings.get("health.memory_min_mb", 500),
 }
 
-ORIGINAL_AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS[:]
-
 PROMETHEUS_EXPORT_MIGRATIONS = False
diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss
index a7b29299653c8e40d7b98304dcf2ea6f68ee4de2..032c28953a9de8c093f037191223c5ddc724faea 100644
--- a/aleksis/core/static/style.scss
+++ b/aleksis/core/static/style.scss
@@ -136,9 +136,8 @@ ul.sidenav li.logo > a:hover {
   background-color: lighten($primary-color, 5%);
 }
 
-li.active > a > .sidenav-badge {
-  background-color: whitesmoke !important;
-  color: $primary-color !important;
+.sidenav-badge {
+  min-width: 2rem!important;
 }
 
 .sidenav li.search {
@@ -286,6 +285,10 @@ form .row {
   margin-bottom: 0;
 }
 
+.help-block {
+  font-size: 75%;
+}
+
 label.chips-checkbox {
   &.active {
     outline: none;
@@ -344,6 +347,11 @@ span.badge .material-icons {
   font-size: 0.9rem;
 }
 
+.chip .material-icons {
+  line-height: 30px;
+  margin-right: 10px;
+  margin-left: -2px;
+}
 
 /*+++++++++*/
 /* Buttons */
@@ -384,6 +392,50 @@ table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped
   background-color: rgba(208, 208, 208, 0.5);
 }
 
+th.orderable > a {
+  color: rgba(0, 0, 0, 0.6);
+  display: block;
+  width: inherit;
+  height: inherit;
+}
+
+th.orderable > a::after {
+  @extend i.material-icons;
+  font-family: 'Material Icons';
+  font-weight: normal;
+  font-style: normal;
+  font-size: 24px;
+  display: inline-block;
+  line-height: 1;
+  text-transform: none;
+  letter-spacing: normal;
+  word-wrap: normal;
+  white-space: nowrap;
+  direction: ltr;
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+  -moz-osx-font-smoothing: grayscale;
+  font-feature-settings: 'liga';
+  float: right;
+  content: "unfold_more";
+}
+
+th.orderable.asc > a {
+  color: inherit;
+
+  &::after {
+    content: "expand_less";
+  }
+}
+
+th.orderable.desc > a {
+  color: inherit;
+
+  &::after {
+    content: "expand_more";
+  }
+}
+
 /*+++++++*/
 /* Print */
 /*+++++++*/
diff --git a/aleksis/core/templates/components/materialize-chips.html b/aleksis/core/templates/components/materialize-chips.html
index e8a37bbb456dd47eb1231ecbc7ec541a7944be74..e02662adc354dcb1e3784804fdf46783b282ea39 100644
--- a/aleksis/core/templates/components/materialize-chips.html
+++ b/aleksis/core/templates/components/materialize-chips.html
@@ -2,6 +2,9 @@
   {% if img %}
     <img class="{{ img_classes }}" src="{{ img }}" alt="{{ alt }}">
   {% endif %}
+  {% if icon %}
+    <i class="material-icons left">{{ icon }}</i>
+  {% endif %}
   {{ content }}
   {% if close %}
     <i class="close material-icons"></i>
diff --git a/aleksis/core/templates/core/notifications.html b/aleksis/core/templates/core/notifications.html
new file mode 100644
index 0000000000000000000000000000000000000000..a49bc9e8e2a8ad87041acbf20e79e0486714cde6
--- /dev/null
+++ b/aleksis/core/templates/core/notifications.html
@@ -0,0 +1,32 @@
+{% extends 'core/base.html' %}
+{% load i18n static dashboard %}
+
+{% block browser_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  {% if object_list %}
+    <ul class="collection">
+      {% for notification in object_list %}
+        <li class="collection-item">
+          <span class="badge new primary-color">{{ notification.sender }}</span>
+          <span class="title">{{ notification.title }}</span>
+          <p>
+            <i class="material-icons left">access_time</i> {{ notification.created }}
+          </p>
+          <p>
+            {{ notification.description }}
+          </p>
+          {% if notification.link %}
+            <p>
+              <a href="{{ notification.link }}">{% blocktrans %}More information →{% endblocktrans %}</a>
+            </p>
+          {% endif %}
+        </li>
+      {% endfor %}
+    </ul>
+  {% else %}
+    <p>{% blocktrans %}No notifications available yet.{% endblocktrans %}</p>
+  {% endif %}
+{% endblock %}
diff --git a/aleksis/core/templates/core/partials/sidenav.html b/aleksis/core/templates/core/partials/sidenav.html
index 4c782285f35a3507a755c4e3d96ddffe007ea83f..54df4fe73e4642a693b63bbad76b7941fe142e76 100644
--- a/aleksis/core/templates/core/partials/sidenav.html
+++ b/aleksis/core/templates/core/partials/sidenav.html
@@ -1,6 +1,6 @@
 {# -*- engine:django -*- #}
 
-{% load menu_generator %}
+{% load menu_generator data_helpers %}
 
 {% get_menu "NAV_MENU_CORE" as core_menu %}
 
@@ -16,6 +16,10 @@
             <i class="material-icons">{{ item.icon }}</i>
           {% endif %}
           {{ item.name }}
+          {% build_badge item as badge %}
+          {% if badge %}
+            <span class="new badge sidenav-badge"> {{ badge }}</span>
+          {% endif %}
         </a>
       </li>
     {% endif %}
@@ -28,6 +32,10 @@
             <i class="material-icons">{{ item.icon }}</i>
           {% endif %}
           {{ item.name }}
+          {% build_badge item as badge %}
+          {% if badge %}
+            <span class="new badge sidenav-badge"> {{ badge }}</span>
+          {% endif %}
         </a>
         <div class="collapsible-body">
           <ul>
@@ -40,6 +48,10 @@
                     <i class="material-icons">{{ menu.icon }}</i>
                   {% endif %}
                   {{ menu.name }}
+                  {% build_badge item as badge %}
+                  {% if badge %}
+                    <span class="new badge sidenav-badge"> {{ badge }}</span>
+                  {% endif %}
                 </a>
               </li>
             {% endfor %}
diff --git a/aleksis/core/templatetags/data_helpers.py b/aleksis/core/templatetags/data_helpers.py
index ab2309f260dd6b039cc722bae86fa71637967a1f..5e263ba370bb732a80cb9d6c5d64880842ff5bb5 100644
--- a/aleksis/core/templatetags/data_helpers.py
+++ b/aleksis/core/templatetags/data_helpers.py
@@ -51,3 +51,14 @@ def parse_json(value: Optional[str] = None) -> Union[dict, None]:
     if not value:
         return None
     return json.loads(value)
+
+
+@register.simple_tag(takes_context=True)
+def build_badge(context: dict, item: dict) -> Any:
+    """Get menu badge content from django-menu-generator dict."""
+    request = context["request"]
+    badge = item.get("badge")
+    if callable(badge):
+        return badge(request)
+    else:
+        return badge
diff --git a/aleksis/core/tests/test_authentication_backends.py b/aleksis/core/tests/test_authentication_backends.py
deleted file mode 100644
index 227f48c1469d97f246132a4bfbef2f5af5f4dbb9..0000000000000000000000000000000000000000
--- a/aleksis/core/tests/test_authentication_backends.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from typing import Optional
-
-from django.contrib.auth import authenticate
-from django.contrib.auth.backends import BaseBackend
-from django.contrib.auth.base_user import AbstractBaseUser
-from django.contrib.auth.models import User
-
-import pytest
-
-from aleksis.core.apps import CoreConfig
-from aleksis.core.util.core_helpers import get_site_preferences
-
-pytestmark = pytest.mark.django_db
-
-
-class DummyBackend(BaseBackend):
-    def authenticate(
-        self, request, username: str, password: str, **kwargs
-    ) -> Optional[AbstractBaseUser]:
-        if username == "foo" and password == "baz":
-            return User.objects.get_or_create(username="foo")[0]
-
-
-backend_name = "aleksis.core.tests.test_authentication_backends.DummyBackend"
-
-
-def test_backends_simple(settings):
-
-    assert not authenticate(username="foo", password="baz")
-
-    assert backend_name not in settings.AUTHENTICATION_BACKENDS
-
-    settings.AUTHENTICATION_BACKENDS.append(backend_name)
-    assert backend_name in settings.AUTHENTICATION_BACKENDS
-
-    assert authenticate(username="foo", password="baz")
-
-    settings.AUTHENTICATION_BACKENDS.remove(backend_name)
-
-    assert not authenticate(username="foo", password="baz")
-
-
-def test_backends_with_activation(settings):
-    assert not authenticate(username="foo", password="baz")
-
-    settings.CUSTOM_AUTHENTICATION_BACKENDS.append(backend_name)
-
-    assert backend_name not in get_site_preferences()["auth__backends"]
-    assert backend_name not in settings.AUTHENTICATION_BACKENDS
-    assert not authenticate(username="foo", password="baz")
-
-    print(get_site_preferences()["auth__backends"])
-    print(get_site_preferences()["auth__backends"].append(backend_name))
-
-    get_site_preferences()["auth__backends"] = [backend_name]
-
-    assert backend_name in get_site_preferences()["auth__backends"]
-    assert backend_name in settings.AUTHENTICATION_BACKENDS
-    assert authenticate(username="foo", password="baz")
-
-    get_site_preferences()["auth__backends"] = []
-
-    assert backend_name not in get_site_preferences()["auth__backends"]
-    assert backend_name not in settings.AUTHENTICATION_BACKENDS
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index da034ca3ad83a7c991e7251c19072076d8f52165..be2fb745f005f00960fa9d5ed97fbbfaf8b937bb 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -58,6 +58,7 @@ urlpatterns = [
     path("group/<int:id_>/edit", views.edit_group, name="edit_group_by_id"),
     path("group/<int:id_>/delete", views.delete_group, name="delete_group_by_id"),
     path("", views.index, name="index"),
+    path("notifications/", views.NotificationsListView.as_view(), name="notifications"),
     path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"),
     path(
         "notifications/mark-read/<int:id_>",
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 18fcfc241ea76d96312e1339fbde6fd526218307..9e9a90faf5547b71f3d556d89d6f2459b743c04b 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -176,6 +176,9 @@ def has_person(obj: Union[HttpRequest, Model]) -> bool:
         else:
             return False
 
+    if obj.is_anonymous:
+        return False
+
     person = getattr(obj, "person", None)
     if person is None:
         return False
@@ -401,3 +404,8 @@ def queryset_rules_filter(
             wanted_objects.add(item.pk)
 
     return queryset.filter(pk__in=wanted_objects)
+
+
+def unread_notifications_badge(request: HttpRequest) -> int:
+    """Generate badge content with the number of unread notifications."""
+    return request.user.person.unread_notifications_count
diff --git a/aleksis/core/util/middlewares.py b/aleksis/core/util/middlewares.py
index 7ac5e5f2cbb710d3c6bb8c1bdda83ca170c96165..c8780e6cc3dcdbcab62f2a139b5cfa26178ee0ac 100644
--- a/aleksis/core/util/middlewares.py
+++ b/aleksis/core/util/middlewares.py
@@ -2,8 +2,8 @@ from typing import Callable
 
 from django.http import HttpRequest, HttpResponse
 
-from ..models import DummyPerson
-from .core_helpers import has_person
+from ..models import DummyPerson, Person
+from .core_helpers import get_site_preferences, has_person
 
 
 class EnsurePersonMiddleware:
@@ -12,19 +12,43 @@ class EnsurePersonMiddleware:
     It is needed to inject a dummy person to a superuser that would otherwise
     not have an associated person, in order they can get their account set up
     without external help.
+
+    In addition, if configured in preferences, it auto-creates or links persons
+    to regular users if they match.
     """
 
     def __init__(self, get_response: Callable):
         self.get_response = get_response
 
     def __call__(self, request: HttpRequest) -> HttpResponse:
-        if not has_person(request):
-            if request.user.is_superuser:
-                # Super-users get a dummy person linked
-                dummy_person = DummyPerson(
-                    first_name=request.user.first_name, last_name=request.user.last_name
-                )
-                request.user.person = dummy_person
+        if not has_person(request) and not request.user.is_anonymous:
+            prefs = get_site_preferences()
+            if (
+                prefs.get("account__auto_link_person", False)
+                and request.user.first_name
+                and request.user.last_name
+            ):
+                if prefs.get("account__auto_create_person"):
+                    person, created = Person.objects.get_or_create(
+                        email=request.user.email,
+                        defaults={
+                            "first_name": request.user.first_name,
+                            "last_name": request.user.last_name,
+                        },
+                    )
+                    person.user = request.user
+                else:
+                    person = Person.objects.filter(email=request.user.email).first()
+                    if person:
+                        person.user = request.user
+                        person.save()
+
+        if request.user.is_superuser and not has_person(request):
+            # Super-users get a dummy person linked
+            dummy_person = DummyPerson(
+                first_name=request.user.first_name, last_name=request.user.last_name
+            )
+            request.user.person = dummy_person
 
         response = self.get_response(request)
         return response
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 41b36acfbcff7573366a2328d2d0f9f2a92fe1df..d110b30f63c8f6748198c3fc8268e81bb0caab9b 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -109,6 +109,18 @@ def index(request: HttpRequest) -> HttpResponse:
     return render(request, "core/index.html", context)
 
 
+class NotificationsListView(PermissionRequiredMixin, ListView):
+    permission_required = "core.view_notifications"
+    template_name = "core/notifications.html"
+
+    def get_queryset(self) -> QuerySet:
+        return self.request.user.person.notifications.order_by("-created")
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        self.get_queryset().filter(read=False).update(read=True)
+        return super().get_context_data(**kwargs)
+
+
 def about(request: HttpRequest) -> HttpResponse:
     """About page listing all apps."""
     context = {}
diff --git a/tox.ini b/tox.ini
index e1283a1b72150383c5c2c9b7fbf43abb6cdf6ea2..f09d243fd2ddcd42c0eae1a6c298c08eb9557a13 100644
--- a/tox.ini
+++ b/tox.ini
@@ -35,6 +35,7 @@ commands =
 
 [testenv:build]
 commands_pre =
+    poetry run sh -c "cd aleksis; aleksis-admin compilemessages"
 commands = poetry build
 
 [testenv:docs]