diff --git a/backend/app.py b/backend/app.py
index 896f1035fd748df697776c66690ada75f16bd637..40e07302edfa3c0e63b8ded02a57581f5bd63f07 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,3 +1,4 @@
+from apscheduler.schedulers.background import BackgroundScheduler
 from flask import Flask, jsonify
 from flask_cors import CORS
 from flask_jwt_extended import JWTManager
@@ -16,6 +17,7 @@ from areas import apps
 from areas import auth
 from areas import roles
 from cliapp import cliapp
+import helpers.provision
 from web import login
 
 from database import db
@@ -37,6 +39,7 @@ import cluster_config
 from config import *
 import logging
 import migration_reset
+import os
 import sys
 
 # Configure logging.
@@ -66,38 +69,63 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS
 app.logger.setLevel(logging.INFO)
 
 cors = CORS(app)
+provisioner = helpers.provision.Provision()
 
 db.init_app(app)
 
-# We'll now perform some initialization routines. Because these have to be done
-# once at startup, not for every gunicorn worker, we take a machine-wide lock
-# for this.
-init_lock = NamedAtomicLock('dashboard_init')
-if init_lock.acquire():
-    try:
-        with app.app_context():
-            # We have reset the alembic migration history at Stackspin version 2.2.
-            # This checks whether we need to prepare the database to follow that
-            # change.
-            migration_reset.reset()
-        flask_migrate.Migrate(app, db)
+def init_routines():
+    # We'll now perform some initialization routines. Because these have to be done
+    # once at startup, not for every gunicorn worker, we take a machine-wide lock
+    # for this.
+    init_lock = NamedAtomicLock('dashboard_init')
+    if init_lock.acquire():
         try:
             with app.app_context():
-                flask_migrate.upgrade()
-        except Exception as e:
-            app.logger.info(f"upgrade failed: {type(e)}: {e}")
-            sys.exit(2)
-
-        # We need this app context in order to talk the database, which is managed by
-        # flask-sqlalchemy, which assumes a flask app context.
+                # We have reset the alembic migration history at Stackspin version 2.2.
+                # This checks whether we need to prepare the database to follow that
+                # change.
+                migration_reset.reset()
+            flask_migrate.Migrate(app, db)
+            try:
+                with app.app_context():
+                    flask_migrate.upgrade()
+            except Exception as e:
+                app.logger.info(f"upgrade failed: {type(e)}: {e}")
+                sys.exit(2)
+
+            # We need this app context in order to talk the database, which is managed by
+            # flask-sqlalchemy, which assumes a flask app context.
+            with app.app_context():
+                # Load the list of apps from a configmap and store any missing ones in the
+                # database.
+                cluster_config.populate_apps()
+                # Same for the list of oauthclients.
+                cluster_config.populate_oauthclients()
+        finally:
+            init_lock.release()
+
+    # We define this wrapper because the SCIM provisioning code needs to access the
+    # database, which needs a flask app context.
+    def provision():
         with app.app_context():
-            # Load the list of apps from a configmap and store any missing ones in the
-            # database.
-            cluster_config.populate_apps()
-            # Same for the list of oauthclients.
-            cluster_config.populate_oauthclients()
-    finally:
-        init_lock.release()
+            provisioner.reconcile()
+    # Set up a generic task scheduler (cron-like).
+    # TODO: there should really only be one of those per flask instance, while now
+    # this is executed once for every worker thread -- and also for flask CLI runs.
+    scheduler = BackgroundScheduler()
+    scheduler.start()
+    # Add a job to run the provisioning reconciliation routine regularly.
+    scheduler.add_job(provision, 'interval', minutes=1)
+
+if RUN_INIT:
+    logging.info("WERKZEUG_RUN_MAIN: {}".format(os.environ.get("WERKZEUG_RUN_MAIN", "unset")))
+    # In development mode, the app code is loaded twice, but only one of these
+    # times this variable is set.
+    if os.environ.get("WERKZEUG_RUN_MAIN") == "true":
+        logging.info("Running initialization code.")
+        init_routines()
+else:
+    logging.info("Not running initialization code.")
 
 app.register_blueprint(api_v1)
 app.register_blueprint(web)
diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py
index 069c73751643f42af0619e65c7d6425d4802138d..50e7da7b5b9d2804f013c06695bc1ae89ba51794 100644
--- a/backend/areas/apps/models.py
+++ b/backend/areas/apps/models.py
@@ -1,9 +1,10 @@
 """Everything to do with Apps"""
 
-import os
 import base64
+import enum
+import os
 
-from sqlalchemy import ForeignKey, Integer, String, Boolean
+from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Unicode
 from sqlalchemy.orm import relationship
 
 from database import db
@@ -202,6 +203,19 @@ class App(db.Model):
         return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
 
 
+class ProvisionStatus(enum.Enum):
+    SyncNeeded = "SyncNeeded"
+    Provisioned = "Provisioned"
+    # Provisioning is not necessary for this user/role, for
+    # example because the user has no access to this app.
+    NotProvisioned = "NotProvisioned"
+    # SCIM Provisioning is not supported for this particular app.
+    NotSupported = "NotSupported"
+    # Something went wrong; more information can be found in the
+    # `last_provision_message`.
+    Error = "Error"
+
+
 class AppRole(db.Model):  # pylint: disable=too-few-public-methods
     """
     The AppRole object, stores the roles Users have on Apps
@@ -210,8 +224,22 @@ class AppRole(db.Model):  # pylint: disable=too-few-public-methods
     user_id = db.Column(String(length=64), primary_key=True)
     app_id = db.Column(Integer, ForeignKey("app.id"), primary_key=True)
     role_id = db.Column(Integer, ForeignKey("role.id"))
+    provision_status = db.Column(
+        Enum(
+            ProvisionStatus,
+            native_enum=False,
+            length=32,
+            values_callable=lambda _: [str(member.value) for member in ProvisionStatus]
+        ),
+        nullable=False,
+        default=ProvisionStatus.SyncNeeded,
+        server_default=ProvisionStatus.SyncNeeded.value
+    )
+    last_provision_attempt = db.Column(DateTime, nullable=True)
+    last_provision_message = db.Column(Unicode(length=256), nullable=True)
 
     role = relationship("Role")
+    app = relationship("App")
 
     def __repr__(self):
         return (f"role_id: {self.role_id}, user_id: {self.user_id},"
diff --git a/backend/config.py b/backend/config.py
index 6e5d37ac1221925e8403612674225bd0ccf5314d..f8cbf03ce46ecf4f0aa75b6ff4e51caa386c4d25 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -23,3 +23,8 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
 LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true"
 
 DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1')
+
+SCIM_URL = os.environ.get("SCIM_URL")
+SCIM_TOKEN = os.environ.get("SCIM_TOKEN")
+# Use this to help our flask code figure out when to run its initialization routines.
+RUN_INIT = os.environ.get("DASHBOARD_RUN_INIT", "False").lower() in ('true', '1')
diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f606485c23cda95bbc724a9aefdbb8ed2d00726
--- /dev/null
+++ b/backend/helpers/provision.py
@@ -0,0 +1,277 @@
+from datetime import datetime
+import json.decoder
+import logging
+import ory_kratos_client
+from ory_kratos_client.api import identity_api
+import requests
+
+from areas.apps.models import App, AppRole, ProvisionStatus
+from areas.roles.models import Role
+import config
+from database import db
+from helpers.kratos_user import KratosUser
+
+class ProvisionError(Exception):
+    pass
+
+class User:
+    def __init__(self, kratos_id, scim_id, displayName):
+        self.kratos_id = kratos_id
+        self.scim_id = scim_id
+        self.displayName = displayName
+
+    def ref(self):
+        return f"{config.SCIM_URL}Users/{self.scim_id}"
+
+class Group:
+    def __init__(self, scim_id, displayName, members):
+        self.scim_id = scim_id
+        self.displayName = displayName
+        self.members = members
+        self.modified = False
+
+    def add_member(self, user):
+        if user.scim_id not in self.members:
+            logging.info(f"Adding user to dict: {user.displayName} ({user.scim_id})")
+            self.members[user.scim_id] = user
+            self.modified = True
+
+    def remove_member(self, user):
+        if user.scim_id in self.members:
+            logging.info(f"Found user to remove from dict: {user.displayName} ({user.scim_id})")
+            del self.members[user.scim_id]
+            self.modified = True
+
+    def debug(self):
+        logging.info(f"Group {self.displayName} ({self.scim_id})")
+        for _, member in self.members.items():
+            logging.info(f"  with user {member.displayName} ({member.scim_id})")
+
+# Read from the database which users need to be provisioned on which apps, and
+# do the corresponding SCIM calls to those apps to do the actual provisioning.
+class Provision:
+    def __init__(self):
+        # Set up kratos API client.
+        kratos_admin_api_configuration = ory_kratos_client.Configuration(host=config.KRATOS_ADMIN_URL, discard_unknown_keys=True)
+        kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+        self.kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
+
+    def reconcile(self):
+        logging.info("Reconciling user provisioning")
+        # logging.info(f"SCIM URL: {config.SCIM_URL}")
+        # logging.info(f"SCIM token: {config.SCIM_TOKEN}")
+        existing_users = self._get_existing_users()
+        for userId, u in existing_users.items():
+            logging.info(f"Existing user: {u.displayName} ({userId})")
+        existing_groups = self._get_existing_groups()
+        for _, g in existing_groups.items():
+            g.debug()
+        # We will modify this group in-memory over the course of the user reconciliation.
+        # After we have reconciled all users, we will update the group in a single operation.
+        # We could also use PATCH operations instead to add single users as we
+        # go, but the Nextcloud SCIM app does not support that.
+        admin_group = existing_groups.get('admin', None)
+        if admin_group is None:
+            raise ProvisionError("Admin group could not be found, aborting.")
+        # TODO: we later need to retry Error ones as well.
+        app_roles = db.session.query(AppRole).filter(
+            AppRole.provision_status == ProvisionStatus.SyncNeeded
+        )
+        for app_role in app_roles:
+            # logging.info(f"found app_role: {app_role}")
+            app_role.last_provision_attempt = datetime.now()
+            existing_user = existing_users.get(app_role.user_id, None)
+            if existing_user is not None:
+                logging.info(f"User {app_role.user_id} already exists in the app.")
+            if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
+                if existing_user is None:
+                    logging.info("User without access does not exist yet, so nothing to do.")
+                    # We have not provisioned this user in this app yet, so we
+                    # don't have to do anything at this point.
+                    app_role.provision_status = ProvisionStatus.Provisioned
+                    app_role.last_provision_message = "Provisioning not required for user without access."
+                    db.session.commit()
+                    continue
+                else:
+                    logging.info("User without access exists in the app; we continue so we can disable the user in the app.")
+            app = app_role.app
+            # logging.info(f"which has app: {app}")
+            if app.slug != "nextcloud":
+                # logging.info(f"we don't support that one")
+                app_role.provision_status = ProvisionStatus.NotSupported
+                app_role.last_provision_message = f"App does not support automatic user provisioning."
+                db.session.commit()
+                continue
+            try:
+                self._provision_user(app_role, existing_user, admin_group)
+                new_status = ProvisionStatus.Provisioned
+                new_message = "User successfully provisioned."
+            except ProvisionError as ex:
+                new_status = ProvisionStatus.Error
+                new_message = str(ex)
+            app_role.provision_status = new_status
+            app_role.last_provision_message = new_message
+            db.session.commit()
+            # let's do only one at a time for now
+            # break
+        logging.info("Before provisioning admin group:")
+        admin_group.debug()
+        if admin_group.modified:
+            logging.info("Admin group was modified, so updating it via SCIM.")
+            self._provision_group(admin_group)
+        else:
+            logging.info("Admin group was not modified.")
+
+    # Provision the user via SCIM PUT or POST, based on the user and role
+    # information in `app_role`, and on the existing user object in the app
+    # `existing_user` (previously obtained via SCIM GET). Also update the
+    # `admin_group` object so we may later add or remove the user to/from the
+    # admin group.
+    def _provision_user(self, app_role, existing_user, admin_group):
+        logging.info(f"Reconciling user {app_role.user_id}")
+        # Get the related user object
+        logging.info(f"Info: Getting user data from Kratos.")
+        kratos_user = KratosUser(self.kratos_identity_api, app_role.user_id)
+        headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
+            active = False
+        else:
+            active = True
+        logging.info(f"Active user: {active}")
+        data = {
+            'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'],
+            'externalId': app_role.user_id,
+            'active': active,
+            # Sadly Nextcloud doesn't allow changing the userName, so we set it
+            # to something unique and stable.
+            # https://github.com/nextcloud/server/issues/5488
+            # We add the `stackspin-` prefix to make this compatible with the
+            # username generated by the sociallogin (SSO) Nextcloud app.
+            'userName': f"stackspin-{app_role.user_id}",
+            'displayName': kratos_user.name,
+            'emails': [{
+                'value': kratos_user.email,
+                'primary': True
+            }],
+        }
+        if existing_user is None:
+            url = f"{config.SCIM_URL}bearer/Users"
+            response = requests.post(url, headers=headers, json=data)
+        else:
+            url = f"{config.SCIM_URL}bearer/Users/{existing_user.scim_id}"
+            response = requests.put(url, headers=headers, json=data)
+        logging.info(f"SCIM url: {url}")
+        logging.info(f"SCIM http status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not json")
+            logging.info(response.content)
+            raise ProvisionError("App returned non-json data in SCIM user put/post.")
+        logging.info(f"got: {response_json}")
+        # {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'id':
+        # 'Greenhostie', 'externalId': '316cbd5c-7b69-4a27-8a3b-96b3ec056e99',
+        # 'meta': None, 'userName': 'Greenhostie', 'name': {'formatted':
+        # 'Greenhostie', 'familyName': None, 'givenName': None, 'middleName': None,
+        # 'honorificPrefix': None, 'honorificSuffix': None}, 'displayName':
+        # 'Greenhostie', 'nickName': None, 'profileUrl': None, 'title': None,
+        # 'userType': None, 'preferredLanguage': None, 'locale': None, 'timezone':
+        # None, 'active': True, 'password': None, 'emails': [{'type': None,
+        # 'primary': True, 'display': None, 'value': 'arie+scim@greenhost.nl',
+        # '$ref': None}], 'phoneNumbers': None, 'ims': None, 'photos': None,
+        # 'addresses': None, 'groups': None, 'entitlements': None, 'roles': None,
+        # 'x509Certificates': None}
+        user = User(app_role.user_id, response_json['id'], kratos_user.name)
+        if app_role.role_id == Role.ADMIN_ROLE_ID:
+            logging.info(f"Adding user to admin group: {user.displayName} ({user.kratos_id})")
+            admin_group.add_member(user)
+        else:
+            logging.info(f"Removing user from admin group: {user.displayName} ({user.kratos_id})")
+            admin_group.remove_member(user)
+        logging.info("After adding/removing user:")
+        admin_group.debug()
+
+    def _get_existing_users(self):
+        logging.info(f"Info: Getting list of current users from app via SCIM.")
+        url = f"{config.SCIM_URL}bearer/Users"
+        headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        response = requests.get(url, headers=headers)
+        logging.info(f"SCIM http status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not json")
+            logging.info(response.content)
+            raise ProvisionError("Failed to get existing users from Nextcloud")
+        # logging.info(f"got: {response_json}")
+        # Make a dictionary of the users, using their externalId as key, which
+        # is the kratos user ID.
+        users = {}
+        for u in response_json['Resources']:
+            kratos_id = u['externalId']
+            if not kratos_id:
+                logging.info(f"Got user without externalId: {u}")
+                # Users that were created in Nextcloud by SSO, before SCIM was
+                # introduced in Stackspin, will not have `externalId` set, so
+                # we get the Kratos ID from the `id` attribute instead.
+                if u['id'].startswith('stackspin-'):
+                    kratos_id = u['id'][len('stackspin-'):]
+                else:
+                    # This is a user that was not created by Stackspin (either
+                    # SSO or SCIM), so we'll ignore it.
+                    continue
+            users[kratos_id] = User(kratos_id, u['id'], u['displayName'])
+        return users
+
+    def _get_existing_groups(self):
+        logging.info(f"Info: Getting list of current groups from Nextcloud via SCIM.")
+        url = f"{config.SCIM_URL}bearer/Groups"
+        headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        response = requests.get(url, headers=headers)
+        logging.info(f"SCIM http status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not json")
+            logging.info(response.content)
+            raise ProvisionError("Failed to get existing groups from Nextcloud")
+        logging.info(f"got: {response_json}")
+        groups = {}
+        for group in response_json['Resources']:
+            members = {}
+            for member in group['members']:
+                scim_id = member['value']
+                members[scim_id] = User(None, scim_id, member['display'])
+            groups[group['id']] = Group(group['id'], group['displayName'], members)
+        return groups
+
+    def _provision_group(self, group):
+        logging.info(f"Reconciling group {group.scim_id}")
+        headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        member_data = [
+            {'value': member.scim_id, 'display': member.displayName, '$ref': member.ref()}
+            for _, member in group.members.items()]
+        logging.info(f"Will update admin group with member data {member_data}")
+        data = {
+            'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Group'],
+            'displayName': group.displayName,
+            'members': member_data,
+        }
+        url = f"{config.SCIM_URL}bearer/Groups/{group.scim_id}"
+        response = requests.put(url, headers=headers, json=data)
+        logging.info(f"SCIM http status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not json")
+            logging.info(response.content)
+            raise ProvisionError("App returned non-json data in SCIM group put.")
+        logging.info(f"got: {response_json}")
diff --git a/backend/migrations/versions/7d27395c892a_new_migration.py b/backend/migrations/versions/7d27395c892a_new_migration.py
index 94a1d844c0918712970147183025150a28ccd37c..5c1a3332615421e89f20395d866d992aa5881ee3 100644
--- a/backend/migrations/versions/7d27395c892a_new_migration.py
+++ b/backend/migrations/versions/7d27395c892a_new_migration.py
@@ -16,11 +16,11 @@ down_revision = None
 branch_labels = None
 depends_on = None
 
-conn = op.get_bind()
-inspector = Inspector.from_engine(conn)
-tables = inspector.get_table_names()
-
 def upgrade():
+    conn = op.get_bind()
+    inspector = Inspector.from_engine(conn)
+    tables = inspector.get_table_names()
+
     if "app" not in tables:
         op.create_table(
             "app",
@@ -69,4 +69,4 @@ def downgrade():
     op.drop_table("oauthclient_app")
     op.drop_table("app_role")
     op.drop_table("role")
-    op.drop_table("app")
\ No newline at end of file
+    op.drop_table("app")
diff --git a/backend/migrations/versions/825262488cd9_add_scim_support.py b/backend/migrations/versions/825262488cd9_add_scim_support.py
new file mode 100644
index 0000000000000000000000000000000000000000..f60bce5c71157a4b6d42259ddc4ed464d8425d09
--- /dev/null
+++ b/backend/migrations/versions/825262488cd9_add_scim_support.py
@@ -0,0 +1,57 @@
+"""Add SCIM support for user provisioning
+
+Revision ID: 825262488cd9
+Revises: 7d27395c892a
+Create Date: 2023-03-08 10:50:00
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from sqlalchemy.engine.reflection import Inspector
+
+from helpers.provision import ProvisionStatus
+
+# revision identifiers, used by Alembic.
+revision = '825262488cd9'
+down_revision = '7d27395c892a'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+    op.add_column(
+        "app_role",
+        sa.Column(
+            "provision_status",
+            sa.Enum(
+                ProvisionStatus,
+                native_enum=False,
+                length=32,
+                values_callable=lambda _: [str(member.value) for member in ProvisionStatus]
+            ),
+            nullable=False,
+            default=ProvisionStatus.SyncNeeded,
+            server_default=ProvisionStatus.SyncNeeded.value
+        ),
+    )
+    op.add_column(
+        "app_role",
+        sa.Column(
+            "last_provision_attempt",
+            sa.DateTime,
+            nullable=True
+        ),
+    )
+    op.add_column(
+        "app_role",
+        sa.Column(
+            "last_provision_message",
+            sa.Unicode(length=256),
+            nullable=True
+        ),
+    )
+
+def downgrade():
+    op.drop_column("app_role", "provision_status")
+    op.drop_column("app_role", "last_provision_attempt")
+    op.drop_column("app_role", "last_provision_message")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index fc2836ad096a8f50d217569cc0a9b8eccdc5db34..6c939b7b02f3fb43b7d409fdf28af36294e84985 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,3 +1,4 @@
+APScheduler==3.9.1
 attrs==21.4.0
 black==22.1.0
 certifi==2021.10.8
diff --git a/docker-compose.yml b/docker-compose.yml
index 655be3e58c31e6d70fefdab2b1f40398e6fee636..ac0f1dea0d649558007942089e03aed8fbb84078 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -54,7 +54,7 @@ services:
       - "$KUBECONFIG:/.kube/config"
     depends_on:
       - kube_port_mysql
-    entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"]
+    entrypoint: ["bash", "-c", "DASHBOARD_RUN_INIT=true flask run --host $$(hostname -i)"]
   kube_port_kratos_admin:
     image: bitnami/kubectl:1.26.2
     user: "${KUBECTL_UID}:${KUBECTL_GID}"