diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3bc7757cccf5c2468d3969bb70754f0ca4a4484..f89f0ea0b3e3aebb791deae380cd9bed0b8bd6ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
 - Make info modals slightly wider, to make sure you can see the full contents
   also for slightly larger fonts.
 - Upgrade to tailwind v3, and update several other javascript dependencies.
+- Implement basic SCIM functionality for automatic user provisioning. "Basic"
+  because setup is still manual and will need to be automated later.
 
 ## [0.9.2]
 
diff --git a/backend/app.py b/backend/app.py
index 06db7d65e6edd9947b356375b2b59e360a79b891..b25e68020e3e62d8b3a2296944e67d7805f5c997 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
@@ -18,6 +19,7 @@ from areas import resources
 from areas import roles
 from areas import tags
 from cliapp import cliapp
+import helpers.provision
 from web import login
 
 from database import db
@@ -73,6 +75,7 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS
 app.logger.setLevel(logging.INFO)
 
 cors = CORS(app)
+provisioner = helpers.provision.Provision()
 
 db.init_app(app)
 
@@ -108,6 +111,19 @@ def init_routines():
         # Same for the list of oauthclients.
         cluster_config.populate_oauthclients()
 
+    if provisioner.enabled:
+        # 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():
+                provisioner.reconcile()
+        # Set up a generic task scheduler (cron-like).
+        scheduler = BackgroundScheduler()
+        scheduler.start()
+        # Add a job to run the provisioning reconciliation routine regularly.
+        # TODO: decrease the frequency once development settles.
+        scheduler.add_job(provision, 'interval', minutes=1)
+
 # `init_routines` should only run once per dashboard instance. To enforce this
 # we have different behaviour for production and development mode:
 # * We have "preload" on for gunicorn, so this file is loaded only once, before
diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py
index d2b411cbb1791b3431452da2e8c69e4c976d14b3..5d983dee629394f13a2159a4e8f406d2b26ebb1a 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
@@ -180,6 +181,21 @@ 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"
+    # This user needs to be deleted from this app.
+    ToDelete = "ToDelete"
+    # 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
@@ -188,8 +204,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/areas/users/user_service.py b/backend/areas/users/user_service.py
index 8b7270d8d73d949efdc0356b41c192773e1a366c..af7e60fd37cbc8bfb4c0067533109e73a2e94e98 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -15,6 +15,7 @@ from flask import current_app
 from config import KRATOS_ADMIN_URL
 from database import db
 from areas.apps import App, AppRole, AppsService
+from areas.apps.models import ProvisionStatus
 from areas.roles import Role, RoleService
 from areas.tags import TagUser
 from helpers import KratosApi
@@ -148,22 +149,7 @@ class UserService:
             app_roles = data["app_roles"]
             for ar in app_roles:
                 app = App.query.filter_by(slug=ar["name"]).first()
-                app_role = AppRole.query.filter_by(
-                    user_id=id, app_id=app.id).first()
-
-                if app_role:
-                    # There is already a role set for this user and app, so we
-                    # edit it.
-                    app_role.role_id = ar["role_id"] if "role_id" in ar else None
-                else:
-                    # There is no role set yet for this user and app, so we
-                    # create a new one.
-                    appRole = AppRole(
-                        user_id=id,
-                        role_id=ar["role_id"] if "role_id" in ar else None,
-                        app_id=app.id,
-                    )
-                    db.session.add(appRole)
+                cls.set_user_role(id, app.id, ar["role_id"] if "role_id" in ar else None)
 
         if data.get("tags") is not None:
             UserStackspinData.setTags(id, data["tags"])
@@ -172,6 +158,26 @@ class UserService:
 
         return cls.get_user(id)
 
+    @classmethod
+    def set_user_role(cls, user_id, app_id, role_id):
+        app_role = AppRole.query.filter_by(user_id=user_id, app_id=app_id).first()
+        if app_role:
+            # There is already a role set for this user and app, so we
+            # edit it.
+            app_role.role_id = role_id
+            # Mark the app role so the SCIM routine will pick it up at
+            # its next run.
+            app_role.provision_status = ProvisionStatus.SyncNeeded
+        else:
+            # There is no role set yet for this user and app, so we
+            # create a new one.
+            appRole = AppRole(
+                user_id=user_id,
+                role_id=role_id,
+                app_id=app_id,
+            )
+            db.session.add(appRole)
+
     @classmethod
     def put_multiple_users(cls, user_editing_id, data):
         for user_data in data["users"]:
@@ -192,6 +198,9 @@ class UserService:
 
                     if app_role:
                         app_role.role_id = ar["role_id"] if "role_id" in ar else None
+                        # Mark the app role so the SCIM routine will pick it up at
+                        # its next run.
+                        app_role.provision_status = ProvisionStatus.SyncNeeded
                         db.session.commit()
                     else:
                         appRole = AppRole(
@@ -211,7 +220,7 @@ class UserService:
     def delete_user(id):
         app_role = AppRole.query.filter_by(user_id=id).all()
         for ar in app_role:
-            db.session.delete(ar)
+            ar.provision_status = ProvisionStatus.ToDelete
         db.session.commit()
 
     @classmethod
diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py
index 4c41ab53aecea3e893f8646f4344f616ab967660..41dec63ec81f0ee3159661842be42e5aa8164f87 100644
--- a/backend/cliapp/cliapp/cli.py
+++ b/backend/cliapp/cliapp/cli.py
@@ -113,6 +113,7 @@ def cleanup_users(dry_run):
             if not dry_run:
                 print("Deleting.")
                 user.delete()
+                UserService.delete(user.uuid)
     if dry_run:
         print(f"Would delete {number_inactive_users} users out of {number_users} total.")
     else:
@@ -236,7 +237,8 @@ def setrole(email, app_slug, role):
     """Set role for a user
     :param email: Email address of user to assign role
     :param app_slug: Slug name of the app, for example 'nextcloud'
-    :param role: Role to assign. currently only 'admin', 'user'
+    :param role: Role to assign. Currently only 'admin', 'user', 'none'/'no
+      access'.
     """
 
     current_app.logger.info(f"Assigning role {role} to {email} for app {app_slug}")
@@ -244,40 +246,23 @@ def setrole(email, app_slug, role):
     # Find user
     user = KratosUser.find_by_email(kratos_identity_api, email)
 
-    if role not in ("admin", "user", "none"):
-        print("At this point the only accepted roles are 'admin', 'user' and 'none'.")
-        sys.exit(1)
-
     if not user:
         print("User not found. Abort")
         sys.exit(1)
 
-    app_obj = db.session.query(App).filter(App.slug == app_slug).first()
-    if not app_obj:
+    app = db.session.query(App).filter(App.slug == app_slug).first()
+    if not app:
         print("App not found. Abort.")
         sys.exit(1)
 
-    role_obj = (
-        db.session.query(AppRole)
-        .filter(AppRole.app_id == app_obj.id)
-        .filter(AppRole.user_id == user.uuid)
-        .first()
-    )
-
-    # Always delete the old role for this app and user if it exists.
-    if role_obj:
-        db.session.delete(role_obj)
-
-    # If the new role is not "none", add it.
-    if role in ("admin", "user"):
-        role = Role.query.filter(func.lower(Role.name) == func.lower(role)).first()
-
-        obj = AppRole()
-        obj.user_id = user.uuid
-        obj.app_id = app_obj.id
-        obj.role_id = role.id if role else None
+    if role == "none":
+        role = "no access"
+    role = Role.query.filter(func.lower(Role.name) == func.lower(role)).first()
+    if not role:
+        print("Role not found. Abort.")
+        sys.exit(1)
 
-        db.session.add(obj)
+    UserService.set_user_role(user.uuid, app.id, role.id)
 
     db.session.commit()
 
@@ -347,7 +332,7 @@ def delete_user(email):
     if not user:
         current_app.logger.error(f"User with email {email} not found.")
         sys.exit(1)
-    user.delete()
+    UserService.delete_user(user.uuid)
 
 
 @user_cli.command("create")
diff --git a/backend/config.py b/backend/config.py
index 831f41caa0c67ae9ec493ef7b06e8b6f18be7125..f43ee28b16e2ce8ca7a648d21bc25d28d1d16e1a 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -30,9 +30,15 @@ if os.environ.get("TELEPRESENCE_ROOT"):
         KUBECONFIG = os.environ["TELEPRESENCE_MOUNTS"]
     else:
         KUBECONFIG = os.environ.get("TELEPRESENCE_ROOT") + os.environ["TELEPRESENCE_MOUNTS"]
+    print(f"KUBECONFIG from telepresence: {KUBECONFIG}")
+    print(os.stat(KUBECONFIG))
 else:
     TELEPRESENCE = False
     KUBECONFIG = None
 
 DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1')
+
 ENFORCE_2FA = os.environ.get("DASHBOARD_ENFORCE_2FA", "False").lower() in ('true', '1')
+
+SCIM_URL = os.environ.get("SCIM_URL")
+SCIM_TOKEN = os.environ.get("SCIM_TOKEN")
diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b1f01f000286597f65b274aaa224dc54ef143fe
--- /dev/null
+++ b/backend/helpers/provision.py
@@ -0,0 +1,304 @@
+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):
+        if config.SCIM_TOKEN is None:
+            logging.warn("SCIM_TOKEN is not set, so disabling SCIM")
+            self.enabled = False
+            return
+        if config.SCIM_URL is None:
+            logging.warn("SCIM_URL is not set, so disabling SCIM")
+            self.enabled = False
+            return
+        self.enabled = True
+        logging.info(f"Enabling SCIM, with URL {config.SCIM_URL}")
+
+        # 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 app_supported(self, app):
+        return (app.slug == "nextcloud")
+
+    def reconcile(self):
+        logging.info("Reconciling user provisioning")
+        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.in_((ProvisionStatus.SyncNeeded, ProvisionStatus.ToDelete))
+        )
+        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
+            if not self.app_supported(app):
+                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, app, 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, app, existing_user, admin_group):
+        logging.info(f"Reconciling user {app_role.user_id}")
+        scim_headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        if app_role.provision_status == ProvisionStatus.ToDelete:
+            if existing_user is None:
+                db.session.delete(app_role)
+                db.session.commit()
+                return
+            else:
+                logging.info(f"Deleting user {app_role.user_id} from {app.slug}")
+                url = f"{config.SCIM_URL}/Users/{existing_user.scim_id}"
+                response = requests.delete(url, headers=scim_headers)
+                logging.info(f"SCIM http status: {response.status_code}")
+                if response.status_code == 204:
+                    db.session.delete(app_role)
+                    db.session.commit()
+                    return
+                else:
+                    logging.info(f"Error returned by SCIM deletion: {response.content}")
+
+        # 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)
+        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}/Users"
+            response = requests.post(url, headers=scim_headers, json=data)
+        else:
+            url = f"{config.SCIM_URL}/Users/{existing_user.scim_id}"
+            response = requests.put(url, headers=scim_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}/Users"
+        scim_headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        response = requests.get(url, headers=scim_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}/Groups"
+        scim_headers = {
+            'Authorization': 'Bearer ' + config.SCIM_TOKEN
+        }
+        response = requests.get(url, headers=scim_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}")
+        scim_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}/Groups/{group.scim_id}"
+        response = requests.put(url, headers=scim_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..2541f090bf1b4d90eb01c63c875d0c4127a1a62b
--- /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: fdb28e81f5c2
+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 = 'fdb28e81f5c2'
+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 0aa91c2fd5ad014f2574d90dbda762e668a010f0..244e4e8b576dc4e66de300dfda2b6cc84ab89137 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/dev.sh b/dev.sh
index e9f383a8f3083e0d16250534b2bc8a0642a4a5c1..486097c783e0b79fe476c1a923b97fd9e5201232 100755
--- a/dev.sh
+++ b/dev.sh
@@ -103,14 +103,14 @@ runBackend() {
       echo "Stopping any previous intercept for dashboard-backend..."
       telepresence leave dashboard-backend
       echo "Starting new intercept for dashboard-backend..."
-      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true -- env TELEPRESENCE_MODE=native flask run --reload
+      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --env-file=./backend.env -- env TELEPRESENCE_MODE=native flask run --reload
       deactivate
       popd > /dev/null
       ;;
     "docker")
       echo "Stopping any previous intercept for dashboard-backend..."
       telepresence leave dashboard-backend
-      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --docker-run -- --dns $(telepresenceDns) -e TELEPRESENCE_MODE=docker dashboard-backend:test
+      telepresence intercept dashboard-backend --service=dashboard-backend --port 5000:80 --mount=true --replace --docker-run -- --dns $(telepresenceDns) -e TELEPRESENCE_MODE=docker dashboard-backend:test
   esac
 }