diff --git a/meta-isar/recipes-installer/deploy-image/files/usr/bin/installer_ui.sh b/meta-isar/recipes-installer/deploy-image/files/usr/bin/installer_ui.sh
index aa906221..6f2433e6 100644
--- a/meta-isar/recipes-installer/deploy-image/files/usr/bin/installer_ui.sh
+++ b/meta-isar/recipes-installer/deploy-image/files/usr/bin/installer_ui.sh
@@ -209,6 +209,20 @@ ui_progress_gauge() {
     return "$status"
 }
 
+#--------------------------------------------------------------------------
+# ui_reencrypt_volume_gauge <device> <index> <count>
+#
+# Shows a gauge for one encrypted volume, fed with percentages on stdin
+# (see sys_luks_reencrypt_volume).
+#--------------------------------------------------------------------------
+ui_reencrypt_volume_gauge() {
+    local device="$1"
+    local index="$2"
+    local count="$3"
+
+    ui_progress_gauge "Securing volume $index/$count ($device) with a new encryption key.\nDo not power off."
+}
+
 #--------------------------------------------------------------------------
 # ui_stop_progress_gauge
 #
diff --git a/meta-isar/recipes-installer/deploy-image/files/usr/bin/sys_api.sh b/meta-isar/recipes-installer/deploy-image/files/usr/bin/sys_api.sh
index 26b39108..4f246697 100644
--- a/meta-isar/recipes-installer/deploy-image/files/usr/bin/sys_api.sh
+++ b/meta-isar/recipes-installer/deploy-image/files/usr/bin/sys_api.sh
@@ -273,18 +273,215 @@ sys_bmap_options_for_image() {
     fi
 }
 
+#--------------------------------------------------------------------------
+# sys_luks_list_volumes <target_device>
+#
+# Prints the LUKS2 volumes found on target_device (and its partitions),
+# one per line, once the target has been verified to be offline and every
+# volume to be a pristine provisioning container: a single keyslot, no
+# tokens and no pending reencryption. Unencrypted targets yield no output
+# and need neither cryptsetup nor jq; encrypted ones yield no output (with a
+# warning) when either is missing, so that key rotation is skipped.
+#
+# Returns:
+#   0 on success (also when tools are missing, see above), 1 when the
+#   target is busy or a volume is unsupported (reason on stderr).
+#--------------------------------------------------------------------------
+sys_luks_list_volumes() {
+    local target_device="$1"
+    local devices device mounts fstype metadata probe_status
+    local holders
+    local volumes=()
+
+    partprobe "$target_device" || true
+    ! command -v udevadm >/dev/null || udevadm settle || true
+
+    devices=$(lsblk --list --noheadings --paths --output NAME "$target_device") || return 1
+    while IFS= read -r device; do
+        mounts=$(lsblk --nodeps --noheadings --output MOUNTPOINTS "$device")
+        if [ -n "${mounts//[[:space:]]/}" ]; then
+            echo "Error: $device is mounted" >&2
+            return 1
+        fi
+        holders=(/sys/class/block/"${device##*/}"/holders/*)
+        if [ -e "${holders[0]}" ]; then
+            echo "Error: $device has active holders" >&2
+            return 1
+        fi
+
+        probe_status=0
+        fstype=$(blkid --probe --output value --match-tag TYPE "$device" 2>/dev/null) || probe_status=$?
+        if [ "$probe_status" != 0 ] && [ "$probe_status" != 2 ]; then
+            echo "Error: cannot probe $device" >&2
+            return 1
+        fi
+        [ "$fstype" = crypto_LUKS ] || continue
+
+        # Optional tools: without them keys are not rotated (see sys_luks_rotate_keys).
+        if ! command -v cryptsetup >/dev/null || ! command -v jq >/dev/null; then
+            echo "Warning: $device is encrypted but cryptsetup/jq are missing, skipping key rotation" >&2
+            return 0
+        fi
+        if ! cryptsetup isLuks --type luks2 "$device"; then
+            echo "Error: $device is not a LUKS2 volume" >&2
+            return 1
+        fi
+        metadata=$(cryptsetup luksDump --dump-json-metadata "$device") || return 1
+        if ! jq -e '
+                (.tokens | length) == 0 and
+                (.keyslots | length) == 1 and
+                all(.keyslots[]; .type == "luks2") and
+                (.segments | length) == 1 and
+                all(.segments[]; .type == "crypt" and ((.flags // []) | length) == 0)
+            ' <<< "$metadata" >/dev/null; then
+            echo "Error: unsupported or interrupted LUKS state on $device" >&2
+            return 1
+        fi
+        volumes+=("$device")
+    done <<< "$devices"
+
+    [ ${#volumes[@]} -eq 0 ] || printf '%s\n' "${volumes[@]}"
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_make_keyfile <device>
+#
+# Creates a private key file holding the initial passphrase of <device> and
+# prints its path. The passphrase is empty unless the optional hook
+# sys_luks_passphrase_hook is defined; it is called with the device and
+# must print the passphrase on stdout (no trailing newline is added).
+#--------------------------------------------------------------------------
+sys_luks_make_keyfile() {
+    local device="$1"
+    local keyfile passphrase=""
+
+    if declare -F sys_luks_passphrase_hook >/dev/null; then
+        passphrase=$(sys_luks_passphrase_hook "$device") || return 1
+    fi
+    keyfile=$(mktemp /run/installer-luks-key.XXXXXX) || return 1
+    printf '%s' "$passphrase" > "$keyfile" || { rm -f -- "$keyfile"; return 1; }
+    echo "$keyfile"
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_reencrypt_volume <device>
+#
+# Generates a new volume key for a provisioning LUKS2 volume, unlocking it
+# with its initial passphrase (see sys_luks_make_keyfile), and emits one
+# integer percentage per line on stdout so callers can drive a progress
+# indicator. The volume is offline and a failed run requires reinstalling,
+# so no resilience journal is kept (this halves the amount of data written).
+#
+# Returns:
+#   0 when the volume was reencrypted and verified, 1 otherwise.
+#--------------------------------------------------------------------------
+sys_luks_reencrypt_volume() {
+    local device="$1"
+    local keyfile log uuid metadata status
+
+    keyfile=$(sys_luks_make_keyfile "$device") || return 1
+    uuid=$(cryptsetup luksUUID "$device") || { rm -f -- "$keyfile"; return 1; }
+    log=$(mktemp /run/installer-luks-reencrypt.XXXXXX) || { rm -f -- "$keyfile"; return 1; }
+
+    # Progress is printed on stdout, errors on stderr (kept for reporting).
+    cryptsetup reencrypt --batch-mode --force-offline-reencrypt \
+        --resilience none --progress-json --progress-frequency 1 \
+        --key-file "$keyfile" "$device" 2> "$log" |
+        jq -R --unbuffered -r '
+            fromjson? | select(.device_size != null) |
+            (((.device_bytes | tonumber) * 100) / (.device_size | tonumber) | floor)'
+    status=${PIPESTATUS[0]}
+    [ "$status" = 0 ] || cat "$log" >&2
+    rm -f -- "$log"
+
+    if [ "$status" = 0 ]; then
+        [ "$(cryptsetup luksUUID "$device")" = "$uuid" ] || status=1
+    fi
+    if [ "$status" = 0 ]; then
+        cryptsetup open --test-passphrase --key-file "$keyfile" "$device" || status=1
+    fi
+    if [ "$status" = 0 ]; then
+        metadata=$(cryptsetup luksDump --dump-json-metadata "$device") || status=1
+        jq -e 'all(.keyslots[]; .type != "reencrypt") and
+            all(.segments[]; .type == "crypt" and ((.flags // []) | length) == 0)' \
+            <<< "$metadata" >/dev/null || status=1
+    fi
+
+    rm -f -- "$keyfile"
+    [ "$status" = 0 ] || echo "Error: reencryption of $device failed" >&2
+    [ "$status" = 0 ]
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_rotate_keys <target_device>
+#
+# Installed images share the volume keys of the image they were built
+# from. Reencrypt every LUKS2 volume of target_device so the installed
+# system uses its own master keys. Must run while the target is offline.
+#
+# Optional hooks (if defined):
+#   sys_luks_passphrase_hook <device>
+#     prints the initial passphrase of <device> (default: empty)
+#   ui_reencrypt_volume_gauge <device> <index> <count>
+#     reads percentages from stdin while a volume is reencrypted
+#
+# Returns:
+#   0 when all volumes were rotated, none are encrypted, or rotation was
+#   skipped because cryptsetup or jq is missing (warning on stderr);
+#   1 otherwise. On failure the target must not be booted and must be
+#   reinstalled.
+#--------------------------------------------------------------------------
+sys_luks_rotate_keys() {
+    local target_device="$1"
+    local volumes=()
+    local device keyfile listing status index=0 count
+
+    listing=$(sys_luks_list_volumes "$target_device") || return 1
+    [ -n "$listing" ] || return 0
+    mapfile -t volumes <<< "$listing"
+    count=${#volumes[@]}
+
+    # Fail before touching any volume when the initial credential is wrong.
+    for device in "${volumes[@]}"; do
+        keyfile=$(sys_luks_make_keyfile "$device") || return 1
+        if ! cryptsetup open --test-passphrase --key-file "$keyfile" "$device"; then
+            echo "Error: initial unlock credential is unavailable for $device" >&2
+            rm -f -- "$keyfile"
+            return 1
+        fi
+        rm -f -- "$keyfile"
+    done
+
+    for device in "${volumes[@]}"; do
+        index=$((index + 1))
+        if declare -F ui_reencrypt_volume_gauge >/dev/null; then
+            sys_luks_reencrypt_volume "$device" | ui_reencrypt_volume_gauge "$device" "$index" "$count"
+            status=${PIPESTATUS[0]}
+        else
+            echo "Reencrypting $device ($index/$count); do not power off."
+            sys_luks_reencrypt_volume "$device" | while read -r percent; do
+                printf '\r%s: %s%%' "$device" "$percent"
+            done
+            status=${PIPESTATUS[0]}
+            echo
+        fi
+        [ "$status" = 0 ] || return 1
+    done
+}
+
 #--------------------------------------------------------------------------
 # sys_install_image_with_lock <image_path> <target_device>
 #
 # Performs the actual bmaptool copy, serializing concurrent installer
-# consoles via flock. When UI hook functions are available, this API
-# uses them to present a progress gauge in attended mode. Consoles that
-# do not get the lock wait for its holder and report its result, which is
-# kept in /run/installer.result.
+# consoles via flock, then rotates the LUKS volume keys of the target
+# (see sys_luks_rotate_keys) before the lock is released. When UI hook
+# functions are available, this API uses them to present progress gauges
+# in attended mode.
 #
 # Optional UI hooks (if defined):
 #   ui_start_progress_gauge <pipe_path>
 #   ui_stop_progress_gauge
+#   ui_reencrypt_volume_gauge <device> <index> <count>
 #--------------------------------------------------------------------------
 sys_install_image_with_lock() {
     local image_path="$1"
@@ -335,6 +532,13 @@ sys_install_image_with_lock() {
             ui_stop_progress_gauge
         fi
 
+        # Still under the lock: give the installed system its own volume keys.
+        if ! sys_luks_rotate_keys "$target_device"; then
+            echo "Volume-key rotation failed. Installation is incomplete; do not boot the target." >&2
+            echo failed > "$result_file"
+            return 1
+        fi
+
         echo ok > "$result_file"
     else
         echo "Installation already running in another console. Waiting for it to finish..."
