diff --git a/src/core/admin.py b/src/core/admin.py
index 48eeed926c9026fb2120cc19f9a37c22121081de..f60d9b884264e263cde4f31ce924e3eba9ffdfe3 100644
--- a/src/core/admin.py
+++ b/src/core/admin.py
@@ -155,11 +155,12 @@ class PlatformUserAdmin(UserAdmin):
 
     list_display = ['username', 'user_type']
     list_filter = ['user_type', 'is_active', 'is_staff']
+    search_fields = ['username', 'display_name', 'communication_channels__address']
 
     fieldsets = (
         (None, {'fields': ('username', 'slug', 'password', 'user_type', 'timezone')}),
-        ('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
-        ('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar_url', 'avatar_config'))}),
+        ('Personal info', {'fields': ('first_name', 'last_name', 'display_name', 'email')}),
+        ('Self Portrayal', {'fields': (('pronouns', 'show_name'), ('status', 'status_public'), ('avatar', 'avatar_url', 'avatar_config'))}),
         ('Accessibility', {'fields': ('theme', 'no_animations', 'colorblind', 'high_contrast', 'tag_ignorelist')}),
         ('Disturbance Settings', {'fields': ('receive_dms', 'receive_dm_images', 'receive_audio', 'receive_video', 'autoaccept_contacts')}),
         ('Permissions', {'fields': ('is_active', 'shadow_banned', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po
index dcf44fd942b408370ad2fb9a86a21bb534ab4299..983a986aba2ce99f648bffe1b91ebecbbe5a83d5 100644
--- a/src/core/locale/de/LC_MESSAGES/django.po
+++ b/src/core/locale/de/LC_MESSAGES/django.po
@@ -1923,9 +1923,16 @@ msgstr "Der Nutzer hat bereits ein Ticket genutzt, 'doppelt hält besser' ist hi
 msgid "ConferenceMemberTicket__token_already_used"
 msgstr "Dieses Ticket wurde bereits genutzt."
 
+#, python-format
+msgid "PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d"
+msgstr "Das Avatar-Bild muss quadratisch sein, min %(min_width)dpx/%(min_height)dpx und max %(max_width)dpx/%(max_height)dpx."
+
 msgid "PlatformUser__type-human"
 msgstr "Mensch"
 
+msgid "PlatformUser__type-speaker"
+msgstr "Vortragender (autom. importiert)"
+
 msgid "PlatformUser__type-service"
 msgstr "Dienst (Service)"
 
@@ -1947,12 +1954,21 @@ msgstr "Art des Benutzers"
 msgid "PlatformUser__type"
 msgstr "Typ"
 
+msgid "PlatformUser__display_name__help"
+msgstr "Wie soll der Nutzer angezeigt werden?"
+
+msgid "PlatformUser__display_name"
+msgstr "Anzeigename"
+
 msgid "PlatformUser__show_name__help"
 msgstr "soll der Nutzername überhaupt angezeigt werden"
 
 msgid "PlatformUser__show_name"
 msgstr "Namen anzeigen"
 
+msgid "PlatformUser__avatar"
+msgstr "Avatar"
+
 msgid "PlatformUser__avatar_url__help"
 msgstr "URL zum Avatar-Bild"
 
diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po
index 7334f860cd9ab7cb71e5d22e63d2eae45948d595..8cde7d22c0cf1e1635943ca7c4446a9128cd8d24 100644
--- a/src/core/locale/en/LC_MESSAGES/django.po
+++ b/src/core/locale/en/LC_MESSAGES/django.po
@@ -1921,9 +1921,16 @@ msgstr "User has used a ticket already."
 msgid "ConferenceMemberTicket__token_already_used"
 msgstr "This ticket has already been used."
 
+#, python-format
+msgid "PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d"
+msgstr "The avatar image must be square, min %(min_width)dpx/%(min_height)dpx and max %(max_width)dpx/%(max_height)dpx."
+
 msgid "PlatformUser__type-human"
 msgstr "human"
 
+msgid "PlatformUser__type-speaker"
+msgstr "speaker (autom. imported)"
+
 msgid "PlatformUser__type-service"
 msgstr "service"
 
@@ -1945,12 +1952,21 @@ msgstr "type of the user"
 msgid "PlatformUser__type"
 msgstr "type"
 
+msgid "PlatformUser__display_name__help"
+msgstr "how shall the user be displayed in the frontend(s)?"
+
+msgid "PlatformUser__display_name"
+msgstr "display name"
+
 msgid "PlatformUser__show_name__help"
 msgstr "select whether a name shall be shown at all"
 
 msgid "PlatformUser__show_name"
 msgstr "show name"
 
+msgid "PlatformUser__avatar"
+msgstr "avatar"
+
 msgid "PlatformUser__avatar_url__help"
 msgstr "URL to the avatar's image"
 
diff --git a/src/core/migrations/0146_platformuser_speaker_changes.py b/src/core/migrations/0146_platformuser_speaker_changes.py
new file mode 100644
index 0000000000000000000000000000000000000000..505eda713d16d802aac07840827daaceeb2e90b3
--- /dev/null
+++ b/src/core/migrations/0146_platformuser_speaker_changes.py
@@ -0,0 +1,41 @@
+# Generated by Django 4.2.6 on 2023-12-21 23:18
+from django.db.models import F
+
+import core.models.users
+import core.validators
+from django.db import migrations, models
+
+
+def set_display_names(apps, schema_editor):
+    PlatformUser = apps.get_model("core", "PlatformUser")
+    PlatformUser.objects.all().update(display_name=F('username'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0145_alter_eventlikecount_unique_together_and_more'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='platformuser',
+            name='accepted_speakersagreement',
+        ),
+        migrations.AddField(
+            model_name='platformuser',
+            name='avatar',
+            field=models.ImageField(blank=True, help_text=core.models.users.get_user_avatar_help_text, null=True, upload_to=core.models.users.get_user_avatar_filename, validators=[core.validators.ImageDimensionValidator(max_size=(1024, 1024), min_size=(64, 64), square=True)], verbose_name='PlatformUser__avatar'),
+        ),
+        migrations.AddField(
+            model_name='platformuser',
+            name='display_name',
+            field=models.CharField(blank=True, help_text='PlatformUser__display_name__help', max_length=100, verbose_name='PlatformUser__display_name'),
+        ),
+        migrations.AlterField(
+            model_name='platformuser',
+            name='user_type',
+            field=models.CharField(choices=[('human', 'PlatformUser__type-human'), ('speaker', 'PlatformUser__type-speaker'), ('service', 'PlatformUser__type-service'), ('assembly', 'PlatformUser__type-assembly'), ('bot', 'PlatformUser__type-bot')], default='human', help_text='PlatformUser__type__help', max_length=10, verbose_name='PlatformUser__type'),
+        ),
+        migrations.RunPython(set_display_names, migrations.RunPython.noop),
+    ]
diff --git a/src/core/models/users.py b/src/core/models/users.py
index ad0d8f576bd48458d367ac67c3a90e94bafa46e7..1939e84ed6337a66f23add6623466be4e372aa6e 100644
--- a/src/core/models/users.py
+++ b/src/core/models/users.py
@@ -1,5 +1,6 @@
 import logging
 import re
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 from uuid import uuid4
 
@@ -24,14 +25,36 @@ from django.utils.timezone import now
 from django.utils.translation import gettext_lazy as _
 
 from ..fields import ConferenceReference
+from ..utils import download_from_url
+from ..validators import ImageDimensionValidator
 
 if TYPE_CHECKING:
     from core.models import ConferenceMember
 
+logger = logging.getLogger(__name__)
+
+
+def get_user_avatar_filename(instance: 'PlatformUser', filename: Path | str):
+    ext = str(filename).rsplit('.', maxsplit=1)[-1]
+    return Path('avatars').joinpath(f'{instance.id}.{ext}')
+
+
+def get_user_avatar_help_text() -> str:
+    return (
+        _('PlatformUser__avatar__help %(min_width)d, %(min_height)d, %(max_width)d, %(max_height)d')
+        % {
+            'min_width': settings.USER_AVATAR_WIDTH_MINIMUM,
+            'min_height': settings.USER_AVATAR_HEIGHT_MINIMUM,
+            'max_width': settings.USER_AVATAR_WIDTH_MAXIMUM,
+            'max_height': settings.USER_AVATAR_HEIGHT_MAXIMUM,
+        },
+    )
+
 
 class PlatformUser(AbstractUser):
     class Type(models.TextChoices):
         HUMAN = ('human', _('PlatformUser__type-human'))
+        SPEAKER = ('speaker', _('PlatformUser__type-speaker'))
         SERVICE = ('service', _('PlatformUser__type-service'))
         ASSEMBLY = ('assembly', _('PlatformUser__type-assembly'))
         BOT = ('bot', _('PlatformUser__type-bot'))
@@ -40,6 +63,9 @@ class PlatformUser(AbstractUser):
         DARK = ('dark', _('PlatformUser__theme-dark'))
         LIGHT = ('light', _('PlatformUser__theme-light'))
 
+    PERSON_TYPES = [Type.HUMAN, Type.SPEAKER]
+    """User types which can be shown as persons on e.g. the frontend."""
+
     INTERACTIVE_TYPES = [Type.HUMAN, Type.BOT]
     """User types which can be interactive (and can thus have an avatar and/or modified by e.g. the Engelsystem)."""
 
@@ -49,9 +75,30 @@ class PlatformUser(AbstractUser):
 
     uuid = models.UUIDField(default=uuid4, unique=True)
     slug = models.SlugField(blank=False, unique=True)
+    display_name = models.CharField(max_length=100, blank=True, help_text=_('PlatformUser__display_name__help'), verbose_name=_('PlatformUser__display_name'))
 
     # self portrayal
     show_name = models.BooleanField(null=True, help_text=_('PlatformUser__show_name__help'), verbose_name=_('PlatformUser__show_name'))
+    avatar = models.ImageField(
+        blank=True,
+        null=True,
+        help_text=get_user_avatar_help_text,
+        upload_to=get_user_avatar_filename,
+        verbose_name=_('PlatformUser__avatar'),
+        validators=[
+            ImageDimensionValidator(
+                min_size=(
+                    settings.USER_AVATAR_WIDTH_MINIMUM,
+                    settings.USER_AVATAR_HEIGHT_MINIMUM,
+                ),
+                max_size=(
+                    settings.USER_AVATAR_WIDTH_MAXIMUM,
+                    settings.USER_AVATAR_HEIGHT_MAXIMUM,
+                ),
+                square=True,
+            ),
+        ],
+    )
     avatar_url = models.URLField(blank=True, null=True, help_text=_('PlatformUser__avatar_url__help'), verbose_name=_('PlatformUser__avatar_url'))
     avatar_config = models.JSONField(blank=True, null=True, help_text=_('PlatformUser__avatar_config__help'), verbose_name=_('PlatformUser__avatar_config'))
     pronouns = models.CharField(max_length=50, blank=True, default='', help_text=_('PlatformUser__pronouns__help'), verbose_name=_('PlatformUser__pronouns'))
@@ -264,10 +311,14 @@ class PlatformUser(AbstractUser):
         return conference.users.filter(user=self, is_staff=True).exists()
 
     @property
-    def display_name(self):
+    def is_person(self):
+        return self.user_type in self.PERSON_TYPES
+
+    def get_display_name(self):
+        result = self.username if self.user_type != self.Type.SPEAKER else (self.first_name + ' ' + self.last_name).strip()
         if self.pronouns:
-            return f'{self.username} ({self.pronouns})'
-        return self.username
+            result += f' ({self.pronouns})'
+        return result
 
     @property
     def guardians(self):
@@ -301,6 +352,9 @@ class PlatformUser(AbstractUser):
         if not self.slug:
             self.generate_slug()
 
+        # update the display name
+        self.display_name = self.get_display_name()
+
         return super().save(*args, update_fields=update_fields, **kwargs)
 
     def has_conference_staffpermission(self, conference, *perms, need_all=False):
@@ -365,6 +419,18 @@ class PlatformUser(AbstractUser):
             .first()
         )
 
+    def load_avatar_from_url(self, url: str, save: bool = True):
+        logger.debug('Loading avatar for user %s (%s) from URL: %s', self.username, self.pk, url)
+
+        filename, data = download_from_url(url)
+        target_filename = get_user_avatar_filename(self, filename)
+
+        logger.debug('Storing avatar for user %s (%s): %s', self.username, self.pk, target_filename)
+        self.avatar.save(target_filename, data)
+        if save:
+            self.save(update_fields=['avatar'])
+        logger.debug('Updated avatar for user %s (%s) as "%s" from URL: %s', self.username, self.pk, target_filename, url)
+
 
 class UserContact(models.Model):
     user = models.ForeignKey(PlatformUser, related_name='contacts', on_delete=models.CASCADE)
diff --git a/src/core/utils.py b/src/core/utils.py
index 8332304b94ac9ac29c4c68009ea67e151b540b36..f7a3ca8cbd381d482afd5ffdb055597c2bf38656 100644
--- a/src/core/utils.py
+++ b/src/core/utils.py
@@ -5,11 +5,14 @@ import shutil
 import subprocess
 import tempfile
 from datetime import UTC, datetime, timedelta
+from io import BytesIO
 from pathlib import Path
 from string import ascii_letters, digits
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional, Tuple, Union
 from urllib.parse import parse_qs, urlparse, urlunparse
 
+import requests
+
 from django.urls import NoReverseMatch
 from django.utils.functional import cached_property
 from django.utils.html import strip_tags
@@ -191,6 +194,35 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> Optional[str]
     return url if fallback_as_is else None
 
 
+def download_from_url(url: str) -> Tuple[str, bytes]:
+    # let requests library fetch the URL
+    r = requests.get(url)
+
+    # bail out if response is not 200
+    r.raise_for_status()
+
+    # try parsing the filename from the Content-Disposition header
+    filename = None
+    if hdr := r.headers.get('Content-Disposition'):
+        # it's surprising that parsing via e.g. EmailMessage works with Wikipedia
+        # but fails in unexpected/unrecognizable ways for frab ...
+        hdr_split = hdr.split(';', maxsplit=1)
+        if len(hdr_split) > 1 and hdr_split[1].startswith('filename='):
+            filename = hdr_split[1][len('filename=') :].rsplit('/', maxsplit=1)[-1]
+
+    # if the above fails, try guessing the filename from the URL's path
+    if not filename:
+        url_parsed = urlparse(url)
+        filename = url_parsed.path.rsplit('/')[-1]
+
+    # read the binary content
+    r.raw.decode_content = True
+    data = BytesIO(r.content)
+
+    # return the result
+    return filename, data
+
+
 class GitCloneError(Exception):
     pass
 
diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py
index 0c445f35199a2a2d84dca768afdfae1859e1a23e..262abdcd744d3120331e723228ed5968122f6eeb 100644
--- a/src/hub/settings/base.py
+++ b/src/hub/settings/base.py
@@ -102,6 +102,10 @@ env = environ.FileAwareEnv(
     BADGE_IMAGE_HEIGHT_MINIMUM=(int, 256),
     BADGE_IMAGE_WIDTH_MAXIMUM=(int, 512),
     BADGE_IMAGE_HEIGHT_MAXIMUM=(int, 512),
+    USER_AVATAR_WIDTH_MINIMUM=(int, 64),
+    USER_AVATAR_HEIGHT_MINIMUM=(int, 64),
+    USER_AVATAR_WIDTH_MAXIMUM=(int, 1024),
+    USER_AVATAR_HEIGHT_MAXIMUM=(int, 1024),
     API_USERS=(list, []),
     DISABLE_REQUEST_LOGGING=(bool, False),
     MOLLY_GUARD=(bool, True),
@@ -614,3 +618,8 @@ BADGE_IMAGE_WIDTH_MINIMUM = env('BADGE_IMAGE_WIDTH_MINIMUM')
 BADGE_IMAGE_HEIGHT_MINIMUM = env('BADGE_IMAGE_HEIGHT_MINIMUM')
 BADGE_IMAGE_WIDTH_MAXIMUM = env('BADGE_IMAGE_WIDTH_MAXIMUM')
 BADGE_IMAGE_HEIGHT_MAXIMUM = env('BADGE_IMAGE_HEIGHT_MAXIMUM')
+
+USER_AVATAR_WIDTH_MINIMUM = env('USER_AVATAR_WIDTH_MINIMUM')
+USER_AVATAR_HEIGHT_MINIMUM = env('USER_AVATAR_HEIGHT_MINIMUM')
+USER_AVATAR_WIDTH_MAXIMUM = env('USER_AVATAR_WIDTH_MAXIMUM')
+USER_AVATAR_HEIGHT_MAXIMUM = env('USER_AVATAR_HEIGHT_MAXIMUM')