diff --git a/mypy.ini b/mypy.ini
--- a/mypy.ini
+++ b/mypy.ini
@@ -13,3 +13,6 @@
 
 [mypy-keycloak.*]
 ignore_missing_imports = True
+
+[mypy-django.*]
+ignore_missing_imports = True
diff --git a/requirements-django.txt b/requirements-django.txt
new file mode 100644
--- /dev/null
+++ b/requirements-django.txt
@@ -0,0 +1 @@
+Django<3
diff --git a/requirements-test.txt b/requirements-test.txt
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,2 +1,3 @@
 pytest
 requests_mock
+pytest-django
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,10 @@
     tests_require=parse_requirements("test"),
     setup_requires=["setuptools-scm"],
     use_scm_version=True,
-    extras_require={"testing": parse_requirements("test")},
+    extras_require={
+        "django": parse_requirements("django"),
+        "testing": parse_requirements("test"),
+    },
     include_package_data=True,
     # entry_points="""
     #     [swh.cli.subcommands]
diff --git a/swh/auth/django/__init__.py b/swh/auth/django/__init__.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/django/__init__.py
@@ -0,0 +1,84 @@
+# Copyright (C) 2020-2021  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU Affero General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from datetime import datetime
+from typing import Optional, Set
+
+from django.contrib.auth.models import User
+
+
+class OIDCUser(User):
+    """
+    Custom User proxy model for remote users storing OpenID Connect
+    related data: profile containing authentication tokens.
+
+    The model is also not saved to database as all users are already stored
+    in the Keycloak one.
+    """
+
+    # OIDC subject identifier
+    sub: str = ""
+
+    # OIDC tokens and session related data, only relevant when a user
+    # authenticates from a web browser
+    access_token: Optional[str] = None
+    expires_at: Optional[datetime] = None
+    id_token: Optional[str] = None
+    refresh_token: Optional[str] = None
+    refresh_expires_at: Optional[datetime] = None
+    scope: Optional[str] = None
+    session_state: Optional[str] = None
+
+    # User permissions
+    permissions: Set[str]
+
+    class Meta:
+        # TODO: To redefine in subclass of this class
+        # Forced to empty otherwise, django complains about it
+        # "Model class swh.auth.django.OIDCUser doesn't declare an explicit app_label
+        # and isn't in an application in INSTALLED_APPS"
+        app_label = ""
+        proxy = True
+
+    def save(self, **kwargs):
+        """
+        Override django.db.models.Model.save to avoid saving the remote
+        users to web application database.
+        """
+        pass
+
+    def get_group_permissions(self, obj=None) -> Set[str]:
+        """
+        Override django.contrib.auth.models.PermissionsMixin.get_group_permissions
+        to get permissions from OIDC
+        """
+        return self.get_all_permissions(obj)
+
+    def get_all_permissions(self, obj=None) -> Set[str]:
+        """
+        Override django.contrib.auth.models.PermissionsMixin.get_all_permissions
+        to get permissions from OIDC
+        """
+        return self.permissions
+
+    def has_perm(self, perm, obj=None) -> bool:
+        """
+        Override django.contrib.auth.models.PermissionsMixin.has_perm
+        to check permission from OIDC
+        """
+        if self.is_active and self.is_superuser:
+            return True
+
+        return perm in self.permissions
+
+    def has_module_perms(self, app_label) -> bool:
+        """
+        Override django.contrib.auth.models.PermissionsMixin.has_module_perms
+        to check permissions from OIDC.
+        """
+        if self.is_active and self.is_superuser:
+            return True
+
+        return any(perm.startswith(app_label) for perm in self.permissions)
diff --git a/swh/auth/django/settings.py b/swh/auth/django/settings.py
new file mode 100644
diff --git a/swh/auth/tests/apptest/__init__.py b/swh/auth/tests/apptest/__init__.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/tests/apptest/__init__.py
@@ -0,0 +1 @@
+default_app_config = "swh.auth.tests.apptest.apps.TestApp"
diff --git a/swh/auth/tests/apptest/apps.py b/swh/auth/tests/apptest/apps.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/tests/apptest/apps.py
@@ -0,0 +1,10 @@
+# Copyright (C) 2021  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from django.apps import AppConfig
+
+
+class TestApp(AppConfig):
+    name = "swh.auth.tests.apptest"
diff --git a/swh/auth/tests/apptest/models.py b/swh/auth/tests/apptest/models.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/tests/apptest/models.py
@@ -0,0 +1,19 @@
+# Copyright (C) 2021  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from django.db import models
+
+from swh.auth.django import OIDCUser
+
+
+class MyUser(OIDCUser):
+    """MyUser class to demonstrate the use of the OIDCUser which adds some attributes to
+       serialize in db."""
+
+    url = models.TextField(null=False)  # type: ignore
+
+    class meta:
+        db_table = "user_client"
+        app_label = "app-label"
diff --git a/swh/auth/tests/apptest/urls.py b/swh/auth/tests/apptest/urls.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/tests/apptest/urls.py
@@ -0,0 +1,17 @@
+"""apptest URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/2.2/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+
+urlpatterns = []  # type: ignore
diff --git a/swh/auth/tests/conftest.py b/swh/auth/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/swh/auth/tests/conftest.py
@@ -0,0 +1,18 @@
+# Copyright (C) 2021  The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU Affero General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from django.conf import settings
+
+
+def pytest_configure():
+    # Basic settings to avoid having to define a django module settings all over the
+    # place so django tests pass fine
+    settings.configure(
+        INSTALLED_APPS=[
+            "django.contrib.auth",
+            "django.contrib.contenttypes",
+            "swh.auth.tests.apptest",
+        ],
+    )
diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py
new file mode 100644
diff --git a/tox.ini b/tox.ini
--- a/tox.ini
+++ b/tox.ini
@@ -7,6 +7,7 @@
 deps =
   pytest-cov
   dev: pdbpp
+  -r requirements-django.txt
 commands =
   pytest --doctest-modules \
          {envsitepackagesdir}/swh/auth \