diff --git a/mypy.ini b/mypy.ini
index fb4140aec848b2fd36037d55f2dcd4437ba630b8..1d827b2ca3466030e8cdde0eb9a7a16ac0011a71 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -13,3 +13,6 @@ ignore_missing_imports = True
 
 [mypy-keycloak.*]
 ignore_missing_imports = True
+
+[mypy-django.*]
+ignore_missing_imports = True
diff --git a/pytest.ini b/pytest.ini
index b712d00030bd5d1c986bd30ee18b46c63f648020..81fe35ec1dc43323dd5fe85b8d8b84ba5422451c 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,2 +1,3 @@
 [pytest]
 norecursedirs = docs .*
+DJANGO_SETTINGS_MODULE = swh.auth.tests.app.apptest.settings
diff --git a/requirements-django.txt b/requirements-django.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9d4695635eae2695bba72bb2aa5fe157b240f2e4
--- /dev/null
+++ b/requirements-django.txt
@@ -0,0 +1 @@
+Django<3
diff --git a/requirements-test.txt b/requirements-test.txt
index 5821059617ef3661bd493ee82a90008fbf6494c9..af0b52a0911dfb44e3b748a30da534fa4b932f4d 100644
--- 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
index 9e6ac781587c4a0e3b950b78a5c297ee9cba3936..f418e2e90342c738315ca8160ab9e11447554c8a 100755
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,10 @@ setup(
     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
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/auth/django/models.py b/swh/auth/django/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..3863c331c549bbd8f6b78ae64140eec7400e56ad
--- /dev/null
+++ b/swh/auth/django/models.py
@@ -0,0 +1,85 @@
+# 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/tests/app/__init__.py b/swh/auth/tests/app/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/auth/tests/app/apptest/__init__.py b/swh/auth/tests/app/apptest/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/swh/auth/tests/app/apptest/apps.py b/swh/auth/tests/app/apptest/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ebdc062175f76438e3e9d061124f12c2297cff7
--- /dev/null
+++ b/swh/auth/tests/app/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.app"
diff --git a/swh/auth/tests/app/apptest/models.py b/swh/auth/tests/app/apptest/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0610e35242d467f2e599a42e4c6a9c1c95478fb
--- /dev/null
+++ b/swh/auth/tests/app/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.models import OIDCUser
+
+
+class AppUser(OIDCUser):
+    """AppUser class to demonstrate the use of the OIDCUser which adds some attributes.
+
+    """
+
+    url = models.TextField(null=False)
+
+    class meta:
+        app_label = "app-label"
diff --git a/swh/auth/tests/app/apptest/settings.py b/swh/auth/tests/app/apptest/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..163c790e9c1a85874089cd82f1cdd198bfdc0a53
--- /dev/null
+++ b/swh/auth/tests/app/apptest/settings.py
@@ -0,0 +1,7 @@
+SECRET_KEY = "o+&ayiuk(y^wh4ijz5e=c2$$kjj7g^6r%z+8d*c0lbpfs##k#7"
+
+INSTALLED_APPS = [
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "swh.auth.tests.app.apptest.apps.TestApp",
+]
diff --git a/swh/auth/tests/app/apptest/urls.py b/swh/auth/tests/app/apptest/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..541ee7b606c2fd1434ed4f9b15f0a44b7e4a6490
--- /dev/null
+++ b/swh/auth/tests/app/apptest/urls.py
@@ -0,0 +1,6 @@
+# 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
+
+urlpatterns = []  # type: ignore
diff --git a/swh/auth/tests/app/manage.py b/swh/auth/tests/app/manage.py
new file mode 100755
index 0000000000000000000000000000000000000000..57db14f201c4e4101bd19863d8cbcfeb4ca60cfb
--- /dev/null
+++ b/swh/auth/tests/app/manage.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+    os.environ.setdefault(
+        "DJANGO_SETTINGS_MODULE", "swh.auth.tests.app.apptest.settings"
+    )
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0ec95a6481802ccbb0a0230ca229010272cff51
--- /dev/null
+++ b/swh/auth/tests/test_models.py
@@ -0,0 +1,71 @@
+# 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 copy import copy
+from typing import Set
+
+import pytest
+
+from swh.auth.tests.app.apptest.models import AppUser
+
+PERMISSIONS: Set[str] = set(["api", "app-label-read"])
+NO_PERMISSION: Set[str] = set()
+
+
+@pytest.fixture
+def appuser():
+    return copy(
+        AppUser(
+            id=666,
+            username="foo",
+            password="bar",
+            first_name="foobar",
+            last_name="",
+            email="foo@bar.org",
+        )
+    )
+
+
+@pytest.fixture
+def appuser_admin(appuser):
+    appuser_admin = appuser
+    appuser_admin.is_active = True
+    appuser_admin.is_superuser = True
+    return appuser_admin
+
+
+def test_django_appuser(appuser):
+    appuser.permissions = PERMISSIONS
+
+    assert appuser.get_group_permissions() == PERMISSIONS
+    assert appuser.get_group_permissions(appuser) == PERMISSIONS
+    assert appuser.get_all_permissions() == PERMISSIONS
+    assert appuser.get_all_permissions(appuser) == PERMISSIONS
+
+    assert "api" in PERMISSIONS
+    assert appuser.has_perm("api") is True
+    assert appuser.has_perm("something") is False
+
+    assert "app-label-read" in PERMISSIONS
+    assert appuser.has_module_perms("app-label") is True
+    assert appuser.has_module_perms("app-something") is False
+
+
+def test_django_appuser_admin(appuser_admin):
+    appuser_admin.permissions = NO_PERMISSION
+
+    assert appuser_admin.get_group_permissions() == NO_PERMISSION
+    assert appuser_admin.get_group_permissions(appuser_admin) == NO_PERMISSION
+
+    assert appuser_admin.get_all_permissions() == NO_PERMISSION
+    assert appuser_admin.get_all_permissions(appuser) == NO_PERMISSION
+
+    assert "foobar" not in PERMISSIONS
+    assert appuser_admin.has_perm("foobar") is True
+    assert "something" not in PERMISSIONS
+    assert appuser_admin.has_perm("something") is True
+
+    assert appuser_admin.has_module_perms("app-label") is True
+    assert appuser_admin.has_module_perms("really-whatever-app") is True
diff --git a/tox.ini b/tox.ini
index de9dbf7745e45a8b139c0b05ce74662caa9a2005..214b63218cba916d18860ae5b2de16dc3e4a2f06 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,6 +4,7 @@ envlist=black,flake8,mypy,py3
 [testenv]
 extras =
   testing
+  django
 deps =
   pytest-cov
   dev: pdbpp