diff --git a/.env_template b/.env_template
index f2279ea8aa82a294cb1ae9fd459cc3c9b904792c..14663ce9aa1904fce922566a5d295de494250c6c 100644
--- a/.env_template
+++ b/.env_template
@@ -1,3 +1,10 @@
 SECRET_KEY=
 DEBUG=True
 DALIA_TRIPLESTORE_BASE_URL=http://fuseki:3030/
+
+IAM4NFDI_CLIENT_ID=
+IAM4NFDI_CLIENT_SECRET=
+# Redirect URI should point to the frontend's /auth/callback/iam4nfdi page (absolute URL).
+IAM4NFDI_REDIRECT_URI=
+# Frontend page to redirect to when OIDC login flow fails (absolute URL).
+IAM4NFDI_LOGIN_ERROR_PAGE=
diff --git a/Dockerfile b/Dockerfile
index 5164b39bf3070cdc2d36e0e0b4036392eef4404a..e35f97d6be7196eb3f57d7dfb350ef218fcf935b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,12 +2,19 @@ FROM python:3.11
 
 WORKDIR /django
 
-COPY manage.py requirements.txt /django/
+COPY manage.py requirements.txt django-allauth.patch /django/
 COPY project/ /django/project
 
 RUN \
     set -ex && \
-    pip install --no-cache-dir -r requirements.txt
+    pip install --no-cache-dir -r requirements.txt && \
+    pip uninstall -y django-allauth[socialaccount] && \
+    git clone https://codeberg.org/allauth/django-allauth.git --depth 1 --branch 65.6.0 && \
+    cd django-allauth && \
+    git apply ../django-allauth.patch && \
+    cd .. && \
+    pip install -e django-allauth[socialaccount]
+    
 
 # Don't care about the relational database at the moment.
 RUN \
diff --git a/django-allauth.patch b/django-allauth.patch
new file mode 100644
index 0000000000000000000000000000000000000000..d5743c605575286f568d5e212b459b11ff3f32bd
--- /dev/null
+++ b/django-allauth.patch
@@ -0,0 +1,29 @@
+diff --git a/allauth/socialaccount/providers/oauth2/client.py b/allauth/socialaccount/providers/oauth2/client.py
+index c0098df..9730260 100644
+--- a/allauth/socialaccount/providers/oauth2/client.py
++++ b/allauth/socialaccount/providers/oauth2/client.py
+@@ -1,3 +1,6 @@
++import os
++import urllib
++
+ import requests
+ from urllib.parse import parse_qsl
+ 
+@@ -28,7 +31,7 @@ class OAuth2Client:
+         self.request = request
+         self.access_token_method = access_token_method
+         self.access_token_url = access_token_url
+-        self.callback_url = callback_url
++        self.callback_url = os.environ.get("IAM4NFDI_REDIRECT_URI")
+         self.consumer_key = consumer_key
+         self.consumer_secret = consumer_secret
+         self.scope_delimiter = scope_delimiter
+@@ -56,7 +59,7 @@ class OAuth2Client:
+             "code": code,
+         }
+         if self.basic_auth:
+-            auth = requests.auth.HTTPBasicAuth(self.consumer_key, self.consumer_secret)
++            auth = requests.auth.HTTPBasicAuth(urllib.parse.quote_plus(self.consumer_key), self.consumer_secret)
+         else:
+             auth = None
+             data.update(
diff --git a/project/dalia/apps.py b/project/dalia/apps.py
index a1402107489a8bd42c3b064972b944bb6a9cee65..87623eb88729bfbae492e5b5dc3c72306e40c986 100644
--- a/project/dalia/apps.py
+++ b/project/dalia/apps.py
@@ -4,3 +4,7 @@ from django.apps import AppConfig
 class DaliaConfig(AppConfig):
     default_auto_field = 'django.db.models.BigAutoField'
     name = 'project.dalia'
+
+    def ready(self):
+        # Implicitly connect signal handlers decorated with @receiver.
+        import project.dalia.signals  # noqa
diff --git a/project/dalia/migrations/0001_initial.py b/project/dalia/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..3eecf5478f68257c56daee8c8fb56001ad63af7f
--- /dev/null
+++ b/project/dalia/migrations/0001_initial.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.1.7 on 2025-03-27 19:31
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('auth', '0012_alter_user_first_name_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserProfile',
+            fields=[
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
+                ('dalia_id', models.UUIDField(default=uuid.uuid4, unique=True)),
+                ('orcid', models.CharField(max_length=37)),
+            ],
+        ),
+    ]
diff --git a/project/dalia/models.py b/project/dalia/models.py
index 71a836239075aa6e6e4ecb700e9c42c95c022d91..7615d3f6ec30a54fb9635612520fffc0aca89dcc 100644
--- a/project/dalia/models.py
+++ b/project/dalia/models.py
@@ -1,3 +1,10 @@
+import uuid
+
+from django.conf import settings
 from django.db import models
 
-# Create your models here.
+
+class UserProfile(models.Model):
+    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True)
+    dalia_id = models.UUIDField(default=uuid.uuid4, unique=True)
+    orcid = models.CharField(max_length=37)  # "https://orcid.org/XXXX-XXXX-XXXX-XXXX"
diff --git a/project/dalia/signals.py b/project/dalia/signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..c01ef14c62bd28ebdf1f7c37f4e28695a095da58
--- /dev/null
+++ b/project/dalia/signals.py
@@ -0,0 +1,12 @@
+from django.conf import settings
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from project.dalia.models import UserProfile
+
+
+@receiver(post_save, sender=settings.AUTH_USER_MODEL)
+def initialize_user_profile_signal(sender, instance, created, **kwargs):
+    if created:
+        UserProfile(user=instance)
+    instance.userprofile.save()
diff --git a/project/dalia/urls.py b/project/dalia/urls.py
index 466998bd1de2851f85855e8ddc65edee69c1d260..1504260ae5c1fabd0682be1c202b82dddf59e933 100644
--- a/project/dalia/urls.py
+++ b/project/dalia/urls.py
@@ -21,4 +21,5 @@ urlpatterns = [
     path('v1/curation/suggest/target-groups', views.CurationSuggestTargetGroupsView.as_view(), name="curation_suggest_target_groups"),
     path('v1/curation/suggest/media-types', views.CurationSuggestMediaTypesView.as_view(), name="curation_suggest_media_types"),
     path('v1/curation/suggest/relation-types', views.CurationSuggestRelationTypesView.as_view(), name="curation_suggest_relation_types"),
+    path("v1/hello/", views.HelloAPIView.as_view()),
 ]
diff --git a/project/dalia/views.py b/project/dalia/views.py
index c44f3e957ddda4b7d25c88f1c900b2647b3b419b..1c34045ff089d5a455a4122b45906e4cbad0904c 100644
--- a/project/dalia/views.py
+++ b/project/dalia/views.py
@@ -1,6 +1,8 @@
 from uuid import UUID
 
+from allauth.headless.contrib.rest_framework.authentication import XSessionTokenAuthentication
 from django.http import HttpResponse, HttpResponseNotFound
+from rest_framework import authentication, permissions
 from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.views import APIView
@@ -182,3 +184,15 @@ class CurationSuggestRelationTypesView(APIView):
             get_relation_types_suggestions(), many=True
         )
         return Response(serializer.data)
+
+
+# Example view to demonstrate how to implement authentication checks
+class HelloAPIView(APIView):
+    authentication_classes = [
+        authentication.SessionAuthentication,
+        XSessionTokenAuthentication,
+    ]
+    permission_classes = [permissions.IsAuthenticated]
+
+    def post(self, request: Request):
+        return Response({"message": f"Hello {request.user.username}!"})
diff --git a/project/settings.py b/project/settings.py
index 5ce711c139fe27d54543fbb442e3cb9cba7cb41c..7d5cdd09871ab9607fba78127e26dc718f96d17c 100644
--- a/project/settings.py
+++ b/project/settings.py
@@ -43,6 +43,11 @@ INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
+    'allauth',
+    'allauth.account',
+    'allauth.socialaccount',
+    'allauth.socialaccount.providers.openid_connect',
+    'allauth.headless',
     'rest_framework',
     'project.dalia',
 ]
@@ -55,6 +60,7 @@ MIDDLEWARE = [
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    'allauth.account.middleware.AccountMiddleware',
 ]
 
 ROOT_URLCONF = 'project.urls'
@@ -75,6 +81,10 @@ TEMPLATES = [
     },
 ]
 
+AUTHENTICATION_BACKENDS = [
+    'allauth.account.auth_backends.AuthenticationBackend',
+]
+
 WSGI_APPLICATION = 'project.wsgi.application'
 
 
@@ -130,6 +140,40 @@ STATIC_URL = 'static/'
 
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 
+# django-allauth settings
+HEADLESS_ONLY = True
+SOCIALACCOUNT_ONLY = True
+ACCOUNT_EMAIL_VERIFICATION = "none"  # required by SOCIALACCOUNT_ONLY = True
+SOCIALACCOUNT_PROVIDERS = {}
+
+IAM4NFDI_CLIENT_ID = os.environ.get("IAM4NFDI_CLIENT_ID", None)
+IAM4NFDI_CLIENT_SECRET = os.environ.get("IAM4NFDI_CLIENT_SECRET", None)
+IAM4NFDI_REDIRECT_URI = os.environ.get("IAM4NFDI_REDIRECT_URI", None)
+IAM4NFDI_LOGIN_ERROR_PAGE = os.environ.get("IAM4NFDI_LOGIN_ERROR_PAGE", None)
+
+if IAM4NFDI_CLIENT_ID and IAM4NFDI_CLIENT_SECRET and IAM4NFDI_REDIRECT_URI and IAM4NFDI_LOGIN_ERROR_PAGE:
+    HEADLESS_FRONTEND_URLS = {
+        "socialaccount_login_error": IAM4NFDI_LOGIN_ERROR_PAGE,
+    }
+
+    SOCIALACCOUNT_PROVIDERS["openid_connect"] = {
+        "APPS": [
+            {
+                "provider_id": "iam4nfdi",
+                "name": "IAM4NFDI Infrastructure Proxy",
+                "client_id": IAM4NFDI_CLIENT_ID,
+                "secret": IAM4NFDI_CLIENT_SECRET,
+                "settings": {
+                    "server_url": "https://infraproxy.nfdi-aai.dfn.de/.well-known/openid-configuration",
+                    "auth_params": {
+                        # response_type can be "code", "id_token" or "token" depending on OIDC provider config.
+                        # "response_type": "...",
+                        "redirect_uri": IAM4NFDI_REDIRECT_URI,
+                    },
+                },
+            },
+        ],
+    }
 
 # dalia app settings
 DALIA_TRIPLESTORE_BASE_URL = os.environ.get("DALIA_TRIPLESTORE_BASE_URL", "http://fuseki:3030/")
diff --git a/project/urls.py b/project/urls.py
index 41455dcb840bb1871638672366c930c9bc31520a..52f284412a83a2212bd72aef47029223353c44a5 100644
--- a/project/urls.py
+++ b/project/urls.py
@@ -20,4 +20,6 @@ from project.dalia import urls as dalia_urls
 
 urlpatterns = [
     path('api/dalia/', include(dalia_urls)),
+    path("api/accounts/", include("allauth.urls")),
+    path("api/_allauth/", include("allauth.headless.urls")),
 ]
diff --git a/requirements.txt b/requirements.txt
index 7643ff7050267397b0fb5bc0560c603264fd5f58..f20cb8ca3b613b4f30be51987e851ef514a0b1dd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,4 @@ djangorestframework-dataclasses==1.3.1
 gunicorn==23.0.0
 rdflib==7.1.3
 python-dotenv==1.0.1
+django-allauth[socialaccount]==65.6.0
diff --git a/tests/project/dalia/test_signals.py b/tests/project/dalia/test_signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..51916a0ae38adcc22319455830a11dc1c2766e57
--- /dev/null
+++ b/tests/project/dalia/test_signals.py
@@ -0,0 +1,34 @@
+from django.contrib.auth import get_user_model
+
+from project.dalia.models import UserProfile
+
+
+def test_initialize_user_profile_signal_initializes_user_profile_after_user_is_created(db):
+    user_model = get_user_model()
+    assert user_model.objects.count() == 0
+
+    user = user_model(username="test")
+    user.save()
+
+    assert user_model.objects.count() == 1
+    assert UserProfile.objects.count() == 1
+    assert user.userprofile.orcid == ""
+    assert user.userprofile.dalia_id
+
+
+def test_initialize_user_profile_signal_does_not_change_user_profile_after_user_is_changed(db):
+    user_model = get_user_model()
+    user = user_model(username="test")
+    user.save()
+    assert user_model.objects.count() == 1
+    assert UserProfile.objects.count() == 1
+    orcid = user.userprofile.orcid
+    dalia_id = user.userprofile.dalia_id
+
+    user.first_name = "abc"
+    user.save()
+
+    assert user_model.objects.count() == 1
+    assert UserProfile.objects.count() == 1
+    assert user.userprofile.orcid == orcid
+    assert user.userprofile.dalia_id == dalia_id