diff --git a/backend/areas/apps/models.py b/backend/areas/apps/models.py
index e9330ab92bafd1eeb36cd93235f405ce68cceb34..88930330bd7289a552ecfa29136431b674f4dd6e 100644
--- a/backend/areas/apps/models.py
+++ b/backend/areas/apps/models.py
@@ -316,3 +316,17 @@ class OAuthClientApp(db.Model):  # pylint: disable=too-few-public-methods
     def __repr__(self):
         return (f"oauthclient_id: {self.oauthclient_id}, app_id: {self.app_id},"
                 f" app: {self.app}")
+
+class ScimAttribute(db.Model):  # pylint: disable=too-few-public-methods
+    """
+    The ScimAttribute object records that a certain user attribute needs to be
+    set in a certain app via SCIM.
+    """
+
+    user_id = db.Column(String(length=64), primary_key=True)
+    app_id = db.Column(Integer, ForeignKey("app.id"), primary_key=True)
+    attribute = db.Column(String(length=64), primary_key=True)
+
+    def __repr__(self):
+        return (f"attribute: {self.attribute}, user_id: {self.user_id},"
+                f" app_id: {self.app_id}")
diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py
index cb59124f188ee74bcb92121321475b5b249bd773..a123cab15f3ad5012fdfb26ccd0aa7032a0cad12 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -20,6 +20,7 @@ from areas.apps.apps_service import AppsService
 from areas.roles import Role
 from helpers import KratosApi
 from helpers.error_handler import KratosError
+from helpers.provision import Provision
 from helpers.threads import request_provision
 
 
@@ -123,11 +124,28 @@ class UserService:
 
     @classmethod
     def put_user(cls, id, data):
-        kratos_data = {
-            "schema_id": "default",
-            "traits": {"email": data["email"], "name": data.get("name", "")},
-        }
-        KratosApi.put("/admin/identities/{}".format(id), kratos_data)
+        # Get the old version of the identity. We need that for comparison to
+        # see if some attributes are changed by our update.
+        old_user = KratosApi.get("/admin/identities/{}".format(id)).json()
+        old_name = old_user["traits"].get("name", "")
+        new_name = data.get("name", "")
+        # Create list of patches with our changes.
+        patches = []
+        patches.append(JsonPatch(op="replace", path="/traits/email", value=data['email']))
+        patches.append(JsonPatch(op="replace", path="/traits/name", value=new_name))
+        # Determine whether we're really changing the name, and if so record
+        # that fact in the database in the form of a ScimAttribute. We'll use
+        # that information later during provisioning via SCIM.
+        if old_name != new_name:
+            current_app.logger.info(f"Name changed for: {data['email']}")
+            current_app.logger.info(f"  old name: {old_name}")
+            current_app.logger.info(f"  new name: {new_name}")
+            Provision.store_attribute(attribute='name', user_id=id)
+        # We used a PUT before, but that deletes any attributes that we don't
+        # specify, which is not so convenient. So we PATCH just the attributes
+        # we're changing instead.
+        patch_doc = JsonPatchDocument(value=patches)
+        kratos_identity_api.patch_identity(id, json_patch_document=patch_doc)
 
         if data["app_roles"]:
             app_roles = data["app_roles"]
diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py
index ca93b5e17198d7828ec7785c6cef5736ade3972e..5174e05325264d51af85938ce18abc7a4f6a976d 100644
--- a/backend/cliapp/cliapp/cli.py
+++ b/backend/cliapp/cliapp/cli.py
@@ -319,6 +319,10 @@ def update_user(email, field, value):
     else:
         current_app.logger.error(f"Field not found: {field}")
 
+    # TODO: this currently deletes the last_recovery and last_login because
+    # `save` uses a simple PUT and is not aware of those fields. We should
+    # switch to PATCH instead, or refactor so `save` uses the same code as
+    # `put_user`.
     user.save()
 
 
diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py
index 877aaca68ca70376b3245191163bdd9d3544e8b9..34f0527a57382d3bbde8d3daffad46866f551ccd 100644
--- a/backend/helpers/kratos_user.py
+++ b/backend/helpers/kratos_user.py
@@ -2,6 +2,7 @@
 Implement the Kratos model to interact with kratos users
 """
 
+from flask import current_app
 import json
 import re
 import urllib.parse
@@ -12,6 +13,10 @@ from urllib.request import Request
 from ory_kratos_client.model.create_identity_body import CreateIdentityBody
 from ory_kratos_client.model.create_recovery_link_for_identity_body \
     import CreateRecoveryLinkForIdentityBody
+from ory_kratos_client.model.json_patch \
+    import JsonPatch
+from ory_kratos_client.model.json_patch_document \
+    import JsonPatchDocument
 from ory_kratos_client.model.update_identity_body import UpdateIdentityBody
 from ory_kratos_client.rest import ApiException as KratosApiException
 
@@ -33,6 +38,7 @@ class KratosUser():
     state = None
     created_at = None
     updated_at = None
+    metadata_admin = None
 
     def __init__(self, api, uuid = None):
         self.api = api
@@ -55,6 +61,9 @@ class KratosUser():
                     self.state = obj.state
                     self.created_at = obj.created_at
                     self.updated_at = obj.updated_at
+                    self.metadata_admin = obj.metadata_admin
+                    if self.metadata_admin is None:
+                        self.metadata_admin = {}
             except KratosApiException as error:
                 raise BackendError(f"Unable to get entry, kratos replied with: {error}", error) from error
 
@@ -107,6 +116,15 @@ class KratosUser():
             except KratosApiException as error:
                 raise BackendError(f"Unable to save entry, kratos replied with:{error}") from error
 
+    def set_metadata(self, **kwargs):
+        current_app.logger.info(f"Setting metadata for {self.__uuid}:")
+        patches = []
+        for k, v in kwargs.items():
+            current_app.logger.info(f"  {k}={v}")
+            patches.append(JsonPatch(op="replace", path=f"/metadata_admin/{k}", value=v))
+        patch_doc = JsonPatchDocument(value=patches)
+        self.api.patch_identity(self.__uuid, json_patch_document=patch_doc)
+
     def delete(self):
         """Deletes the object from kratos
         :raise: BackendError if Krator API call fails
diff --git a/backend/helpers/provision.py b/backend/helpers/provision.py
index beaba6a9dfda17830a6ef201ed0547c739b702a8..41b32edb2968fb3aea67cc3bb652412caaa4935e 100644
--- a/backend/helpers/provision.py
+++ b/backend/helpers/provision.py
@@ -5,8 +5,10 @@ import ory_kratos_client
 from ory_kratos_client.api import identity_api
 import ory_kratos_client.exceptions
 import requests
+from sqlalchemy import exc, select
+from sqlalchemy.sql.expression import literal
 
-from areas.apps.models import App, AppRole, ProvisionStatus
+from areas.apps.models import App, AppRole, ProvisionStatus, ScimAttribute
 from areas.roles.models import Role
 import config
 from database import db
@@ -175,7 +177,7 @@ class Provision:
                 db.session.commit()
             else:
                 logging.info(f"Deleting user {app_role.user_id} from {app.slug}")
-                url = f"{app.scim_url}Users/{existing_user.scim_id}"
+                url = f"{app.scim_url}/Users/{existing_user.scim_id}"
                 response = requests.delete(url, headers=scim_headers)
                 logging.debug(f"SCIM http status: {response.status_code}")
                 if response.status_code == 204:
@@ -210,15 +212,37 @@ class Provision:
             # Zulip does not read the `emails` property, instead getting the
             # email from the `userName` property.
             'userName': kratos_user.email,
-            'displayName': kratos_user.name,
             'emails': [{
                 'value': kratos_user.email,
                 'primary': True
             }],
-            'name': {
-                'formatted': kratos_user.name,
-            },
         }
+        # 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
+        # provisioning run, then we do not set it to prevent overwriting a
+        # displayName the user may have set inside the app, bypassing
+        # Stackspin.
+        name_changed = ScimAttribute.query.filter_by(
+            user_id=app_role.user_id,
+            app_id=app_role.app_id,
+            attribute='name',
+        ).first()
+        logging.debug(f"Name changed: {name_changed}")
+        include_name = existing_user is None or name_changed is not None
+        if include_name:
+            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(
+                attribute='name',
+                user_id=app_role.user_id,
+                app_id=app_role.app_id,
+            )
 
         # Make some app-specific additions and modifications to the SCIM data.
         if app.slug == 'nextcloud':
@@ -230,7 +254,7 @@ class Provision:
             data['userName'] = f"stackspin-{app_role.user_id}"
         if app.slug == 'zulip':
             # Zulip does not accept an empty formatted name.
-            if kratos_user.name is None or kratos_user.name == '':
+            if include_name and (kratos_user.name is None or kratos_user.name == ''):
                 data['name']['formatted'] = "name not set"
             # Zulip doesn't support SCIM user groups, but we can set the user
             # role as a field on the user object.
@@ -287,7 +311,7 @@ class Provision:
         # Track how many users we've received thus far so we know when to stop.
         running_total = 0
         while True:
-            url = f"{app.scim_url}Users?count={count}&startIndex={startIndex}"
+            url = f"{app.scim_url}/Users?count={count}&startIndex={startIndex}"
             scim_headers = {
                 'Authorization': 'Bearer ' + app.scim_token
             }
@@ -417,4 +441,26 @@ class Provision:
             logging.info("SCIM result was not json")
             logging.info(response.content)
             raise ProvisionError("{app.slug} returned non-json data to SCIM group PUT.")
-        logging.info(f"got: {response_json}")
+        logging.debug(f"got: {response_json}")
+
+    @staticmethod
+    def store_attribute(attribute, user_id):
+        select_apps = select(
+            literal(user_id),
+            literal(attribute),
+            App.id
+        ).where(App.scim_url != None)
+        insert = ScimAttribute.__table__.insert().prefix_with('IGNORE').from_select(
+            ['user_id', 'attribute', 'app_id'],
+            select_apps
+        )
+        db.session.execute(insert)
+
+    @staticmethod
+    def done_attribute(attribute, user_id, app_id):
+        logging.debug(f"Deleting ScimAttribute with attribute={attribute} user_id={user_id} app_id={app_id}")
+        db.session.query(ScimAttribute).filter(
+            ScimAttribute.attribute == attribute,
+            ScimAttribute.user_id == user_id,
+            ScimAttribute.app_id == app_id,
+        ).delete()
diff --git a/backend/migrations/versions/9ee5a7d65fa7_scim_app_attributes.py b/backend/migrations/versions/9ee5a7d65fa7_scim_app_attributes.py
new file mode 100644
index 0000000000000000000000000000000000000000..572edc9baff98b2e0509b8e49d6b71f28cc8ddf1
--- /dev/null
+++ b/backend/migrations/versions/9ee5a7d65fa7_scim_app_attributes.py
@@ -0,0 +1,31 @@
+"""Extend SCIM support to include some attributes during provisioning only when
+they are changed, or the user is first created in the app.
+
+Revision ID: 9ee5a7d65fa7
+Revises: 267d280db490
+Create Date: 2024-06-04 15:39:00
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '9ee5a7d65fa7'
+down_revision = '267d280db490'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+    # An entry in this table records that a certain user attribute needs to be
+    # set in a certain app via SCIM.
+    op.create_table(
+        "scim_attribute",
+        sa.Column("user_id", sa.String(length=64), nullable=False),
+        sa.Column("app_id", sa.Integer(), nullable=False),
+        sa.Column("attribute", sa.String(length=64), nullable=False),
+        sa.PrimaryKeyConstraint("user_id", "app_id", "attribute"),
+        sa.ForeignKeyConstraint(["app_id"],["app.id"]),
+    )
+
+def downgrade():
+    op.drop_table("scim_attribute")