diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py
index 0fabd55c83adb1733c58559456242c7b41e05419..95e8fee60d9f797d230e59a273dc68574a68da52 100644
--- a/backend/helpers/provision.py
+++ b/backend/helpers/provision.py
@@ -19,13 +19,14 @@ class ProvisionError(Exception):
     pass
 
 class User:
-    def __init__(self, kratos_id, scim_id, displayName):
+    def __init__(self, kratos_id, scim_id, username, displayName):
         self.kratos_id = kratos_id
         self.scim_id = scim_id
+        self.username = username
         self.displayName = displayName
 
     def ref(self, base_url):
-        return f"{base_url}Users/{self.scim_id}"
+        return f"{base_url}/Users/{self.scim_id}"
 
 class Group:
     def __init__(self, scim_id, displayName, members):
@@ -51,6 +52,89 @@ class Group:
         for _, member in self.members.items():
             logging.debug(f"  with user {member.displayName} ({member.scim_id})")
 
+class ScimUser:
+    """
+    A lower-level helper class that represents the SCIM representation of a
+    user, and knows how to do the actual SCIM POST/PUT calls.
+    """
+
+    __allowed = (
+        'app',
+        'kratos_id',
+        'scim_id',
+        'active',
+        'email',
+        'username',
+        'display_name',
+        'include_name',
+    )
+
+    def __init__(self, **kwargs):
+        self.include_name = False
+        self.active = True
+        for k, v in kwargs.items():
+            assert(k in self.__class__.__allowed)
+            setattr(self, k, v)
+        # According to the SCIM RFC, the `userName` attribute is required, and
+        # it should be how to user identifies to the system.
+        # Zulip does not read the `emails` property, instead getting the
+        # email from the `userName` property.
+        if hasattr(self, 'email') and getattr(self, 'username', None) is None:
+            self.username = self.email
+
+    def _provision_data(self):
+        data = {
+            'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'],
+            'externalId': self.kratos_id,
+            'active': self.active,
+            'userName': self.username,
+        }
+        if hasattr(self, 'email'):
+            data['emails'] = [{
+                'value': self.email,
+                'primary': True
+            }]
+        if self.include_name:
+            data['displayName'] = self.display_name
+            data['name'] = {
+                'formatted': self.display_name,
+            }
+        if hasattr(self, 'role'):
+            data['role'] = self.role
+
+        return data
+
+    def _scim_headers(self):
+        return {
+            'Authorization': 'Bearer ' + self.app.scim_token
+        }
+
+    def create(self):
+        data = self._provision_data()
+        url = f"{self.app.scim_url}/Users"
+        response = requests.post(url, headers=self._scim_headers(), json=data)
+        logging.debug(f"Post SCIM user: {url} with data: {data} getting status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not valid json:")
+            logging.info(response.content)
+            raise ProvisionError("App returned non-json data in SCIM user post.")
+        return response_json
+
+    def update(self):
+        data = self._provision_data()
+        url = f"{self.app.scim_url}/Users/{self.scim_id}"
+        response = requests.put(url, headers=self._scim_headers(), json=data)
+        logging.debug(f"Put SCIM user: {url} with data: {data} getting status: {response.status_code}")
+        try:
+            response_json = response.json()
+        except json.decoder.JSONDecodeError as e:
+            logging.info("SCIM result was not valid json:")
+            logging.info(response.content)
+            raise ProvisionError("App returned non-json data in SCIM user put.")
+        return response_json
+
 # 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:
@@ -183,6 +267,28 @@ class Provision:
                 if response.status_code == 204:
                     db.session.delete(app_role)
                     db.session.commit()
+                elif response.status_code == 400:
+                    error_json = response.json()
+                    try:
+                        not_supported = error_json['detail'].startswith('DELETE operation not supported.')
+                    except KeyError:
+                        not_supported = False
+                    if not_supported:
+                        # Disable the app account as we can't delete it through
+                        # SCIM.
+                        logging.debug("App does not support deleting account, so disabling instead.")
+                        ScimUser(
+                            app=app,
+                            kratos_id=app_role.user_id,
+                            scim_id=existing_user.scim_id,
+                            username=existing_user.username,
+                            active=False,
+                        ).update()
+                        db.session.delete(app_role)
+                        db.session.commit()
+                    else:
+                        logging.info(f"Error returned by SCIM deletion: {response.content}")
+                        raise ProvisionError("App cannot delete user via SCIM.")
                 else:
                     logging.info(f"Error returned by SCIM deletion: {response.content}")
                     raise ProvisionError("App cannot delete user via SCIM.")
@@ -200,23 +306,21 @@ class Provision:
                 return
             else:
                 raise
+        # Existing users that should not have access are disabled using the
+        # SCIM attribute `active`.
         if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
             active = False
         else:
             active = True
         logging.debug(f"Active user: {active}")
-        data = {
-            'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'],
-            'externalId': app_role.user_id,
-            'active': active,
-            # Zulip does not read the `emails` property, instead getting the
-            # email from the `userName` property.
-            'userName': kratos_user.email,
-            'emails': [{
-                'value': kratos_user.email,
-                'primary': True
-            }],
-        }
+        scim_object = ScimUser(
+            app=app,
+            kratos_id=app_role.user_id,
+            active=active,
+            email=kratos_user.email,
+            # This is what at least Zulip needs. For Nextcloud we override this later on.
+            username=kratos_user.email,
+        )
         # Decide whether to include the displayName in the SCIM data. Usually
         # we want that, but if this is an existing user, and we think the
         # displayName as stored in Stackspin has not changed since the last
@@ -231,11 +335,9 @@ class Provision:
         logging.debug(f"Name changed: {name_changed}")
         include_name = existing_user is None or name_changed is not None
         if include_name:
+            scim_object.display_name = kratos_user.name
+            scim_object.include_name = True
             logging.debug(f"Setting name in provisioning request.")
-            data['displayName'] = kratos_user.name
-            data['name'] = {
-                'formatted': kratos_user.name,
-            }
             # Now clear the `name_changed` attribute so we don't set the name
             # again -- until it's changed again in Stackspin.
             Provision.done_attribute(
@@ -251,40 +353,31 @@ class Provision:
             # 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.
-            data['userName'] = f"stackspin-{app_role.user_id}"
+            scim_object.username = f"stackspin-{app_role.user_id}"
         if app.slug == 'zulip':
             # Zulip does not accept an empty formatted name.
             if include_name and (kratos_user.name is None or kratos_user.name == ''):
-                data['name']['formatted'] = "name not set"
+                scim_object.display_name = "name not set"
             # Zulip doesn't support SCIM user groups, but we can set the user
             # role as a field on the user object.
             if app_role.role_id == Role.ADMIN_ROLE_ID:
-                data['role'] = 'owner'
+                scim_object.role = 'owner'
             else:
-                data['role'] = 'member'
+                scim_object.role = 'member'
 
         # Now format the URL and make the SCIM request.
         if existing_user is None:
-            url = f"{app.scim_url}/Users"
-            response = requests.post(url, headers=scim_headers, json=data)
-            logging.debug(f"Post SCIM user: {url} with data: {data} getting status: {response.status_code}")
+            scim_result = scim_object.create()
         else:
-            url = f"{app.scim_url}/Users/{existing_user.scim_id}"
-            response = requests.put(url, headers=scim_headers, json=data)
-            logging.debug(f"Put SCIM user: {url} with data: {data} getting 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.debug(f"got: {response_json}")
+            scim_object.scim_id = existing_user.scim_id
+            scim_result = scim_object.update()
+        logging.debug(f"got: {scim_result}")
         if existing_user is None:
             # Because this is a new user for the app, we should read off its
             # SCIM ID and store that in the Stackspin database.
-            app_role.scim_id = response_json['id']
+            app_role.scim_id = scim_result['id']
             db.session.commit()
-        user = User(app_role.user_id, response_json['id'], kratos_user.name)
+        user = User(app_role.user_id, scim_result['id'], scim_result['userName'], kratos_user.name)
         if app.scim_group_support:
             if app_role.role_id == Role.ADMIN_ROLE_ID:
                 logging.debug(f"Adding user to admin group: {user.displayName} ({user.kratos_id})")
@@ -335,6 +428,9 @@ class Provision:
                 startIndex = startIndex + added
 
     def _get_existing_users(self, app):
+        """
+        Get all current user accounts from the given app via SCIM.
+        """
         scim_users = self._scim_list_users(app)
         # Make a dictionary of the users, using their externalId as key, which
         # is the kratos user ID.
@@ -391,7 +487,7 @@ class Provision:
                     kratos_id = kratos_user.uuid
                 else:
                     kratos_id = app_role.user_id
-            users[kratos_id] = User(kratos_id, u['id'], u['displayName'])
+            users[kratos_id] = User(kratos_id, u['id'], u['userName'], u['displayName'])
         return users
 
     def _get_existing_groups(self, app):
@@ -414,7 +510,7 @@ class Provision:
             members = {}
             for member in group['members']:
                 scim_id = member['value']
-                members[scim_id] = User(None, scim_id, member['display'])
+                members[scim_id] = User(None, scim_id, None, member['display'])
             groups[group['id']] = Group(group['id'], group['displayName'], members)
         return groups