diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs
index ddc6d036f8..8f65e21663 100644
--- a/crates/lib/src/bootc_composefs/finalize.rs
+++ b/crates/lib/src/bootc_composefs/finalize.rs
@@ -118,6 +118,13 @@ pub(crate) async fn composefs_backend_finalize(
     let diff = compute_diff(&pristine_files, &current_files, &new_files)?;
     merge(&current_etc, &current_files, &new_etc, &new_files, &diff)?;
 
+    // Remove /etc/.updated from the new deployment so that ConditionNeedsUpdate=|/etc
+    // services (systemd-sysusers, systemd-tmpfiles) run on the first boot, mirroring
+    // what ostree does in sysroot_finalize_deployment.
+    new_etc
+        .remove_file_optional(".updated")
+        .context("Removing /etc/.updated from staged deployment")?;
+
     // Unmount EROFS
     drop(erofs_tmp_mnt);
 
diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs
index 0b72c1108b..a5e65cd100 100644
--- a/crates/lib/src/bootc_composefs/state.rs
+++ b/crates/lib/src/bootc_composefs/state.rs
@@ -132,16 +132,27 @@ pub(crate) fn initialize_state(
             .run_capture_stderr()?;
     }
 
-    let cp_ret = Command::new("cp")
+    Command::new("cp")
         .args([
             "-a",
             "--remove-destination",
             &format!("{}/etc/.", tempdir.dir.path().as_str()?),
             &format!("{state_path}/etc/."),
         ])
-        .run_capture_stderr();
+        .run_capture_stderr()?;
+
+    // Remove /etc/.updated so that ConditionNeedsUpdate=|/etc services
+    // (e.g. systemd-sysusers, systemd-tmpfiles) run on the first boot of
+    // this deployment, mirroring what ostree does in sysroot_finalize_deployment.
+    // Without this, systemd sees /etc/.updated from the container image and
+    // concludes /etc is already up-to-date, causing sysusers to be skipped.
+    let state_etc = Dir::open_ambient_dir(format!("{state_path}/etc"), ambient_authority())
+        .context("Opening state etc dir")?;
+    state_etc
+        .remove_file_optional(".updated")
+        .context("Removing /etc/.updated")?;
 
-    cp_ret
+    Ok(())
 }
 
 /// Adds or updates the provided key/value pairs in the .origin file of the deployment pointed to
diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs
index f0bc8357be..a9f10710d7 100644
--- a/crates/lib/src/cli.rs
+++ b/crates/lib/src/cli.rs
@@ -630,6 +630,9 @@ pub(crate) enum InternalsOpts {
         late_dir: Option<Utf8PathBuf>,
     },
     FixupEtcFstab,
+    /// Remove orphaned and duplicate entries from /etc/shadow and /etc/gshadow
+    /// before systemd-sysusers runs.
+    SysusersSync,
     /// Should only be used by `make update-generated`
     PrintJsonSchema {
         #[clap(long)]
@@ -2094,6 +2097,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
                 Ok(())
             }
             InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
+            InternalsOpts::SysusersSync => crate::sysusers_cleanup::run(&root),
             InternalsOpts::PrintJsonSchema { of } => {
                 let schema = match of {
                     SchemaType::Host => schema_for!(crate::spec::Host),
diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs
index 09ff63b687..46d8f75b2f 100644
--- a/crates/lib/src/generator.rs
+++ b/crates/lib/src/generator.rs
@@ -20,6 +20,7 @@ const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
 pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
 const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
 const SYSINIT_TARGET: &str = "sysinit.target";
+const SHADOW_SYNC_UNIT: &str = "bootc-sysusers-shadow-sync.service";
 
 /// Called when the root is read-only composefs to reconcile /etc/fstab
 #[context("bootc generator")]
@@ -134,6 +135,28 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
         }
     }
 
+    // === Shadow sync unit: runs for ALL bootc systems (native composefs or ostree) ===
+    // Must be before the ostree-booted guard because native composefs boots do
+    // not write /run/ostree-booted, but still need the shadow sync to clean up
+    // stale shadow/gshadow entries (the rechunk scenario).  Gate on either a
+    // composefs mount source (native composefs boot) or the ostree-booted marker.
+    {
+        let is_composefs = match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
+            Ok(fs) => {
+                fs.source.starts_with("composefs:") || fs.source.starts_with("transient:composefs=")
+            }
+            Err(e) => {
+                tracing::debug!("Could not inspect root filesystem: {e:#}");
+                false
+            }
+        };
+        let is_ostree = root.try_exists(OSTREE_BOOTED)?;
+        if is_composefs || is_ostree {
+            let updated = shadow_sync_generator_impl(root, unit_dir)?;
+            tracing::trace!("Enabled shadow sync: {updated}");
+        }
+    }
+
     // === Ostree-specific generator logic ===
     // Only run on ostree systems (native composefs boots skip below).
     if !root.try_exists(OSTREE_BOOTED)? {
@@ -166,6 +189,31 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
     Ok(())
 }
 
+/// Enable the statically-installed shadow sync unit by symlinking it into
+/// `sysinit.target.wants/` in the generator output directory.
+///
+/// The unit file itself lives at `/usr/lib/systemd/system/bootc-sysusers-shadow-sync.service`
+/// and is shipped with bootc; the generator only performs conditional enablement.
+///
+/// We check existence of `/etc/shadow` rather than writability because systemd
+/// sandboxes generators in a private read-only mount namespace (see
+/// `systemd.generator(7)`), so any writability check would always fail even
+/// though `/etc` will be on its own writable mount by the time the service
+/// actually runs.  The caller already gates on the ostree-booted marker,
+/// which guarantees we are on a bootc system where `/etc` is writable at
+/// service-run time.
+#[context("shadow sync generator")]
+pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
+    if !root.try_exists("etc/shadow")? {
+        tracing::trace!("/etc/shadow not found, skipping shadow sync");
+        return Ok(false);
+    }
+
+    tracing::debug!("/etc/shadow found, enabling {SHADOW_SYNC_UNIT}");
+    enable_unit(unit_dir, SHADOW_SYNC_UNIT, "sysinit.target")?;
+    Ok(true)
+}
+
 /// Parse /etc/fstab and check if the root mount is out of sync with the composefs
 /// state, and if so, fix it.
 fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
@@ -417,5 +465,51 @@ UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot                   xfs     defaul
 
             Ok(())
         }
+
+        #[test]
+        fn test_shadow_sync_no_shadow() -> Result<()> {
+            // No /etc/shadow => should not enable (boot detection is caller's job)
+            let tempdir = fixture()?;
+            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
+            let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
+            assert!(!generated);
+            assert_eq!(unit_dir.entries()?.count(), 0);
+            Ok(())
+        }
+
+        #[test]
+        fn test_shadow_sync_enables_when_shadow_present() -> Result<()> {
+            // /etc/shadow present => enables the static unit
+            let tempdir = fixture()?;
+            tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
+            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
+            let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
+            assert!(generated);
+            // The generator creates a symlink in sysinit.target.wants/; check the
+            // directory entry exists (symlink_contents creates an absolute-path symlink
+            // that cap-std won't follow in a tempdir, so we check metadata directly).
+            let wants = unit_dir.open_dir("sysinit.target.wants")?;
+            let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
+            assert!(meta.is_symlink(), "expected symlink for {SHADOW_SYNC_UNIT}");
+            Ok(())
+        }
+
+        /// Verify that generator() enables the shadow sync unit on traditional
+        /// ostree boots (tmpfs root, OSTREE_BOOTED marker present).
+        #[test]
+        fn test_generator_shadow_sync_on_non_composefs() -> Result<()> {
+            let tempdir = fixture()?;
+            tempdir.atomic_write(OSTREE_BOOTED, "")?;
+            tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
+            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
+            generator(&tempdir, unit_dir)?;
+            let wants = unit_dir.open_dir("sysinit.target.wants")?;
+            let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
+            assert!(
+                meta.is_symlink(),
+                "shadow sync unit must be enabled on non-composefs ostree systems"
+            );
+            Ok(())
+        }
     }
 }
diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs
index 122625ddaa..c7382e2668 100644
--- a/crates/lib/src/lib.rs
+++ b/crates/lib/src/lib.rs
@@ -94,6 +94,7 @@ mod reboot;
 pub mod spec;
 mod status;
 mod store;
+mod sysusers_cleanup;
 mod task;
 mod ukify;
 mod utils;
diff --git a/crates/lib/src/sysusers_cleanup.rs b/crates/lib/src/sysusers_cleanup.rs
new file mode 100644
index 0000000000..7858060836
--- /dev/null
+++ b/crates/lib/src/sysusers_cleanup.rs
@@ -0,0 +1,474 @@
+//! Remove orphaned and duplicate entries from `/etc/shadow` and `/etc/gshadow`
+//! before `systemd-sysusers` runs, preventing fatal "already exists" errors.
+//!
+//! The canonical trigger for this problem is the ublue/rechunk tooling, which
+//! resets `/etc/group` (and optionally `/etc/passwd`) but leaves the shadow
+//! files untouched, producing stale entries.  When `systemd-sysusers` then
+//! tries to create those users/groups it finds them already in the shadow files
+//! and fatally errors, causing subsequent entries to be skipped.
+//!
+//! This module is invoked as `bootc internals sysusers-sync` by the static
+//! `bootc-sysusers-shadow-sync.service` unit, which the generator symlinks into
+//! `sysinit.target.wants/` and which runs `Before=systemd-sysusers.service`.
+
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use std::collections::HashSet;
+use std::io::Write;
+
+use anyhow::{Context, Result};
+use cap_std_ext::cap_std;
+use cap_std_ext::cap_std::fs::Dir;
+use cap_std_ext::dirext::CapStdExtDirExt;
+use fn_error_context::context;
+use ostree_ext::ostree;
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+/// Load usernames from a passwd-format file at `path` within `root`, returning
+/// an empty set if the file doesn't exist.
+fn load_usernames(root: &Dir, path: &str) -> Result<HashSet<String>> {
+    use bootc_sysusers::nameservice::passwd::parse_passwd_content;
+    let mut names = HashSet::new();
+    if let Some(f) = root
+        .open_optional(path)
+        .with_context(|| format!("Opening {path}"))?
+    {
+        let entries = parse_passwd_content(std::io::BufReader::new(f))
+            .with_context(|| format!("Parsing {path}"))?;
+        names.extend(entries.into_iter().map(|e| e.name));
+    }
+    Ok(names)
+}
+
+/// Load group names from a group-format file at `path` within `root`, returning
+/// an empty set if the file doesn't exist.
+fn load_groupnames(root: &Dir, path: &str) -> Result<HashSet<String>> {
+    use bootc_sysusers::nameservice::group::parse_group_content;
+    let mut names = HashSet::new();
+    if let Some(f) = root
+        .open_optional(path)
+        .with_context(|| format!("Opening {path}"))?
+    {
+        let entries = parse_group_content(std::io::BufReader::new(f))
+            .with_context(|| format!("Parsing {path}"))?;
+        names.extend(entries.into_iter().map(|e| e.name));
+    }
+    Ok(names)
+}
+
+// ── RemovedEntries ────────────────────────────────────────────────────────────
+
+/// Entries removed from a shadow-style file, split by reason.
+#[derive(Debug, Default)]
+struct RemovedEntries {
+    orphaned: Vec<String>,
+    duplicates: Vec<String>,
+}
+
+impl RemovedEntries {
+    fn is_empty(&self) -> bool {
+        self.orphaned.is_empty() && self.duplicates.is_empty()
+    }
+}
+
+// ── filter_shadow_file ────────────────────────────────────────────────────────
+
+/// Remove entries from a shadow-style file whose name is not in `valid_names`
+/// or which are duplicates (keeping first occurrence). Returns the sets of
+/// removed entry names, or `None` if the file does not exist. Logging is left
+/// to the caller.
+///
+/// When `sepolicy` is provided the rewritten file is labeled according to the
+/// policy (using the file's canonical absolute path, e.g. `/etc/shadow`). This
+/// preserves the correct SELinux label (`shadow_t` / `gshadow_t`) on the
+/// atomically replaced tempfile, which would otherwise inherit `etc_t` from the
+/// directory's default transition rules.
+fn filter_shadow_file<T>(
+    root: &Dir,
+    path: &str,
+    valid_names: &HashSet<String>,
+    name_fn: impl Fn(&T) -> &str,
+    serialize_fn: impl Fn(&T, &mut Vec<u8>) -> Result<()>,
+    parse_fn: impl Fn(std::io::BufReader<cap_std::fs::File>) -> Result<Vec<T>>,
+    sepolicy: Option<&ostree::SePolicy>,
+) -> Result<Option<RemovedEntries>> {
+    let Some(f) = root
+        .open_optional(path)
+        .with_context(|| format!("Opening {path}"))?
+    else {
+        return Ok(None);
+    };
+    let meta = f.metadata().with_context(|| format!("Stat {path}"))?;
+    use cap_std::fs::MetadataExt as _;
+    let mode = rustix::fs::Mode::from_raw_mode(meta.mode());
+    let entries = parse_fn(std::io::BufReader::new(f))?;
+
+    let mut seen = HashSet::new();
+    let mut removed = RemovedEntries::default();
+
+    let filtered: Vec<T> = entries
+        .into_iter()
+        .filter(|e| {
+            let name = name_fn(e);
+            if !valid_names.contains(name) {
+                removed.orphaned.push(name.to_string());
+                return false;
+            }
+            if !seen.insert(name.to_string()) {
+                removed.duplicates.push(name.to_string());
+                return false;
+            }
+            true
+        })
+        .collect();
+
+    if removed.is_empty() {
+        return Ok(Some(removed));
+    }
+
+    let mut buf = Vec::new();
+    for entry in &filtered {
+        serialize_fn(entry, &mut buf)?;
+    }
+    crate::lsm::atomic_replace_labeled(root, path, mode, sepolicy, |w| {
+        w.write_all(&buf).map_err(Into::into)
+    })
+    .with_context(|| format!("Rewriting {path}"))?;
+
+    Ok(Some(removed))
+}
+
+// ── PwdLock ───────────────────────────────────────────────────────────────────
+
+/// RAII guard that holds the shadow-utils password-file lock (`/etc/.pwd.lock`)
+/// for the duration of its lifetime, matching the locking convention used by
+/// `shadow-utils` (`lckpwdf(3)`) and `systemd-sysusers`.
+///
+/// `locked` tracks whether `lckpwdf` was actually called so that `Drop` only
+/// calls `ulckpwdf` when the lock is genuinely held.
+struct PwdLock {
+    locked: bool,
+}
+
+impl PwdLock {
+    /// Acquire the lock only when `root` is the real root filesystem.
+    /// When operating on a tempdir (unit tests, image builds) lckpwdf would
+    /// try to lock the *host* `/etc/.pwd.lock`, which is wrong, so we skip it.
+    fn acquire_for_root(root: &Dir) -> Result<Self> {
+        #[allow(unsafe_code)]
+        unsafe extern "C" {
+            fn lckpwdf() -> libc::c_int;
+        }
+        // Check if this Dir refers to the real root by comparing its device/inode
+        // to that of "/". If it doesn't, skip locking.
+        let root_meta = root.dir_metadata()?;
+        let real_root_meta = std::fs::metadata("/")?;
+        use cap_std_ext::cap_primitives::fs::MetadataExt as CapMetadataExt;
+        use std::os::unix::fs::MetadataExt;
+        if root_meta.dev() != real_root_meta.dev() || root_meta.ino() != real_root_meta.ino() {
+            tracing::trace!("skipping lckpwdf: not operating on real root");
+            return Ok(PwdLock { locked: false });
+        }
+        // lckpwdf() blocks up to 15 seconds then returns -1 on timeout.
+        #[allow(unsafe_code)]
+        let r = unsafe { lckpwdf() };
+        if r != 0 {
+            anyhow::bail!("lckpwdf() failed: could not acquire /etc/.pwd.lock");
+        }
+        Ok(PwdLock { locked: true })
+    }
+}
+
+impl Drop for PwdLock {
+    fn drop(&mut self) {
+        if !self.locked {
+            return;
+        }
+        #[allow(unsafe_code)]
+        unsafe extern "C" {
+            fn ulckpwdf() -> libc::c_int;
+        }
+        #[allow(unsafe_code)]
+        let _ = unsafe { ulckpwdf() };
+    }
+}
+
+// ── public entry point ────────────────────────────────────────────────────────
+
+/// Remove orphaned and duplicate entries from `/etc/shadow` and `/etc/gshadow`.
+///
+/// For `/etc/shadow`: an entry is orphaned if the username does not appear in
+/// `/etc/passwd` OR `/usr/lib/passwd`. Both are checked because nss-altfiles
+/// places real system users in `/usr/lib/passwd` and those users legitimately
+/// have shadow entries for local PAM authentication.
+///
+/// For `/etc/gshadow`: an entry is orphaned if the group name does not appear
+/// in `/etc/group` OR `/usr/lib/group`. The symmetry with shadow/passwd is
+/// intentional: nss-altfiles places groups in `/usr/lib/group` and those groups
+/// legitimately have gshadow entries. A gshadow entry is only stale when the
+/// group has dropped from *both* locations (the rechunk scenario).
+///
+/// This runs as `bootc-sysusers-shadow-sync.service` before
+/// `systemd-sysusers.service` to prevent fatal "already exists" errors when
+/// sysusers tries to create users/groups whose shadow entries are stale.
+#[context("Fixing orphaned/duplicate entries in /etc/shadow and /etc/gshadow")]
+pub(crate) fn run(root: &Dir) -> Result<()> {
+    use bootc_sysusers::nameservice::gshadow::{GshadowEntry, parse_gshadow_content};
+    use bootc_sysusers::nameservice::shadow::{ShadowEntry, parse_shadow_content};
+
+    // Acquire the shadow-utils/systemd-sysusers lock (/etc/.pwd.lock) for the
+    // duration of this function so our read-modify-write is atomic with respect
+    // to any other process that honours the same locking convention.
+    let _lock = PwdLock::acquire_for_root(root)?;
+
+    // Load the SELinux policy for this root so that rewritten shadow files get
+    // the correct label (e.g. `shadow_t` / `gshadow_t`) rather than inheriting
+    // the directory default (`etc_t`) from the atomically created tempfile.
+    // Returns None on non-SELinux systems or when no policy csum is found.
+    let sepolicy = crate::lsm::new_sepolicy_at(root)?;
+    let sepolicy = sepolicy.as_ref();
+
+    // Build valid user set from both /etc/passwd and /usr/lib/passwd.
+    // nss-altfiles users in /usr/lib/passwd legitimately have shadow entries.
+    let mut valid_users = load_usernames(root, "etc/passwd")?;
+    valid_users.extend(load_usernames(root, "usr/lib/passwd")?);
+
+    // Build valid group set from both /etc/group and /usr/lib/group.
+    // nss-altfiles groups in /usr/lib/group legitimately have gshadow entries.
+    // A gshadow entry is only orphaned when the group is absent from both.
+    let mut valid_groups = load_groupnames(root, "etc/group")?;
+    valid_groups.extend(load_groupnames(root, "usr/lib/group")?);
+
+    // If we couldn't find any valid users at all, skip to avoid
+    // incorrectly wiping shadow on a minimal/unusual system.
+    if valid_users.is_empty() {
+        tracing::debug!("No /etc/passwd or /usr/lib/passwd found, skipping shadow fixup");
+        return Ok(());
+    }
+
+    if let Some(removed) = filter_shadow_file(
+        root,
+        "etc/shadow",
+        &valid_users,
+        |e: &ShadowEntry| e.namp.as_str(),
+        |e, buf| e.to_writer(buf),
+        parse_shadow_content,
+        sepolicy,
+    )? {
+        if !removed.is_empty() {
+            tracing::info!(
+                "etc/shadow: removed {} orphaned ({}), {} duplicate ({}) entries",
+                removed.orphaned.len(),
+                removed.orphaned.join(", "),
+                removed.duplicates.len(),
+                removed.duplicates.join(", "),
+            );
+        }
+    }
+
+    // Guard: if we found no groups at all from either file, skip to avoid
+    // wiping gshadow on an unusual system where group files are absent.
+    if !valid_groups.is_empty() {
+        if let Some(removed) = filter_shadow_file(
+            root,
+            "etc/gshadow",
+            &valid_groups,
+            |e: &GshadowEntry| e.name.as_str(),
+            |e, buf| e.to_writer(buf),
+            parse_gshadow_content,
+            sepolicy,
+        )? {
+            if !removed.is_empty() {
+                tracing::info!(
+                    "etc/gshadow: removed {} orphaned ({}), {} duplicate ({}) entries",
+                    removed.orphaned.len(),
+                    removed.orphaned.join(", "),
+                    removed.duplicates.len(),
+                    removed.duplicates.join(", "),
+                );
+            }
+        }
+    }
+
+    Ok(())
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use anyhow::Result;
+    use cap_std_ext::cap_std;
+    use cap_std_ext::dirext::CapStdExtDirExt;
+
+    use super::*;
+
+    fn setup_etc(td: &cap_std::fs::Dir) -> Result<()> {
+        td.create_dir_all("etc")?;
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_no_orphans() -> Result<()> {
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        setup_etc(&td)?;
+        td.atomic_write(
+            "etc/passwd",
+            "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1::/usr/sbin:/usr/sbin/nologin\n",
+        )?;
+        td.atomic_write(
+            "etc/shadow",
+            "root:*:18912:0:99999:7:::\ndaemon:*:18474:0:99999:7:::\n",
+        )?;
+        td.atomic_write("etc/group", "root:x:0:\ndaemon:x:1:\n")?;
+        td.atomic_write("etc/gshadow", "root:*::\ndaemon:*::\n")?;
+
+        run(&td)?;
+
+        assert_eq!(
+            td.read_to_string("etc/shadow")?,
+            "root:*:18912:0:99999:7:::\ndaemon:*:18474:0:99999:7:::\n"
+        );
+        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\ndaemon:*::\n");
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_orphaned_entry() -> Result<()> {
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        setup_etc(&td)?;
+        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
+        // plocate is in shadow but NOT in passwd or usr/lib/passwd
+        td.atomic_write(
+            "etc/shadow",
+            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n",
+        )?;
+        td.atomic_write("etc/group", "root:x:0:\n")?;
+        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
+
+        run(&td)?;
+
+        // plocate removed from both
+        assert_eq!(
+            td.read_to_string("etc/shadow")?,
+            "root:*:18912:0:99999:7:::\n"
+        );
+        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\n");
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_nss_altfiles_group_gshadow_kept() -> Result<()> {
+        // plocate is in /usr/lib/group (nss-altfiles) but NOT in /etc/group.
+        // Its /etc/gshadow entry must be KEPT — the group is legitimately present
+        // in the system via nss-altfiles, so the gshadow entry is valid.
+        // Only when the group drops from BOTH /etc/group and /usr/lib/group is
+        // the gshadow entry considered orphaned.
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        td.create_dir_all("etc")?;
+        td.create_dir_all("usr/lib")?;
+        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
+        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
+        td.atomic_write("etc/group", "root:x:0:\n")?;
+        // plocate in usr/lib/group (nss-altfiles) but not in etc/group
+        td.atomic_write("usr/lib/group", "plocate:x:999:\n")?;
+        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
+
+        run(&td)?;
+
+        // plocate gshadow entry must be KEPT — group is valid via /usr/lib/group
+        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\nplocate:!::\n");
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_nss_altfiles_group_gshadow_removed() -> Result<()> {
+        // The core rechunk/ublue scenario: plocate dropped from BOTH /etc/group
+        // and /usr/lib/group, but the stale gshadow entry remains.
+        // It must be removed so systemd-sysusers can re-create the group cleanly.
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        td.create_dir_all("etc")?;
+        td.create_dir_all("usr/lib")?;
+        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
+        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
+        td.atomic_write("etc/group", "root:x:0:\n")?;
+        // plocate absent from both /etc/group and /usr/lib/group
+        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
+
+        run(&td)?;
+
+        // plocate gshadow entry must be REMOVED — absent from both group files
+        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\n");
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_nss_altfiles_passwd_user_kept() -> Result<()> {
+        // Users in /usr/lib/passwd (nss-altfiles) legitimately have shadow entries
+        // because /etc/shadow is always local. Their shadow entries must be preserved.
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        td.create_dir_all("etc")?;
+        td.create_dir_all("usr/lib")?;
+        // bin is in /usr/lib/passwd (nss-altfiles), not in /etc/passwd
+        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
+        td.atomic_write("usr/lib/passwd", "bin:x:1:1:bin:/bin:/sbin/nologin\n")?;
+        // bin has a shadow entry — this is valid and must be preserved
+        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\nbin:!!:::::::\n")?;
+        td.atomic_write("etc/group", "root:x:0:\nbin:x:1:\n")?;
+        td.atomic_write("etc/gshadow", "root:*::\nbin:*::\n")?;
+
+        run(&td)?;
+
+        // bin shadow entry preserved (bin is in /usr/lib/passwd)
+        assert_eq!(
+            td.read_to_string("etc/shadow")?,
+            "root:*:18912:0:99999:7:::\nbin:!!:::::::\n"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_duplicate_removed() -> Result<()> {
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        setup_etc(&td)?;
+        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
+        // root appears twice in shadow
+        td.atomic_write(
+            "etc/shadow",
+            "root:*:18912:0:99999:7:::\nroot:*:18912:0:99999:7:::\n",
+        )?;
+        td.atomic_write("etc/group", "root:x:0:\n")?;
+        td.atomic_write("etc/gshadow", "root:*::\n")?;
+
+        run(&td)?;
+
+        // duplicate removed, first kept
+        assert_eq!(
+            td.read_to_string("etc/shadow")?,
+            "root:*:18912:0:99999:7:::\n"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn test_fixup_shadow_no_passwd_skips() -> Result<()> {
+        // No /etc/passwd at all => skip shadow fixup entirely (safety guard)
+        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
+        setup_etc(&td)?;
+        td.atomic_write(
+            "etc/shadow",
+            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n",
+        )?;
+        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
+
+        run(&td)?;
+
+        // Files unchanged — we didn't know what's valid
+        assert_eq!(
+            td.read_to_string("etc/shadow")?,
+            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n"
+        );
+        Ok(())
+    }
+}
diff --git a/crates/sysusers/src/lib.rs b/crates/sysusers/src/lib.rs
index 9b8233332f..369997c402 100644
--- a/crates/sysusers/src/lib.rs
+++ b/crates/sysusers/src/lib.rs
@@ -1,8 +1,7 @@
 //! Parse and generate systemd sysusers.d entries.
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
-#[allow(dead_code)]
-mod nameservice;
+pub mod nameservice;
 
 use std::collections::{BTreeMap, BTreeSet};
 use std::io::{BufRead, BufReader};
diff --git a/crates/sysusers/src/nameservice/group.rs b/crates/sysusers/src/nameservice/group.rs
index 783edc6bb5..f59662428a 100644
--- a/crates/sysusers/src/nameservice/group.rs
+++ b/crates/sysusers/src/nameservice/group.rs
@@ -7,11 +7,11 @@ use std::io::{BufRead, BufReader, Write};
 
 // Entry from group file.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct GroupEntry {
-    pub(crate) name: String,
-    pub(crate) passwd: String,
-    pub(crate) gid: u32,
-    pub(crate) users: Vec<String>,
+pub struct GroupEntry {
+    pub name: String,
+    pub passwd: String,
+    pub gid: u32,
+    pub users: Vec<String>,
 }
 
 impl GroupEntry {
@@ -45,7 +45,7 @@ impl GroupEntry {
     }
 }
 
-pub(crate) fn parse_group_content(content: impl BufRead) -> Result<Vec<GroupEntry>> {
+pub fn parse_group_content(content: impl BufRead) -> Result<Vec<GroupEntry>> {
     let mut groups = vec![];
     for (line_num, line) in content.lines().enumerate() {
         let input =
@@ -73,7 +73,7 @@ pub(crate) fn parse_group_content(content: impl BufRead) -> Result<Vec<GroupEntr
     Ok(groups)
 }
 
-pub(crate) fn load_etc_group(rootfs: &Dir) -> Result<Vec<GroupEntry>> {
+pub fn load_etc_group(rootfs: &Dir) -> Result<Vec<GroupEntry>> {
     let r = rootfs.open("etc/group").map(BufReader::new)?;
     parse_group_content(r)
 }
diff --git a/crates/sysusers/src/nameservice/gshadow.rs b/crates/sysusers/src/nameservice/gshadow.rs
new file mode 100644
index 0000000000..9f4ca93d07
--- /dev/null
+++ b/crates/sysusers/src/nameservice/gshadow.rs
@@ -0,0 +1,121 @@
+//! Helpers for [GShadow file](https://man7.org/linux/man-pages/man5/gshadow.5.html).
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use anyhow::{Context, Result, anyhow};
+use std::io::{BufRead, Write};
+
+/// Entry from gshadow file.
+/// Format: name:password:admins:members
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GshadowEntry {
+    /// group name
+    pub name: String,
+    /// encrypted password (or ! or empty)
+    pub password: String,
+    /// comma-separated list of group administrators
+    pub admins: String,
+    /// comma-separated list of group members
+    pub members: String,
+}
+
+impl GshadowEntry {
+    /// Parse a single gshadow entry.
+    pub fn parse_line(s: impl AsRef<str>) -> Option<Self> {
+        let mut parts = s.as_ref().splitn(4, ':');
+        let entry = Self {
+            name: parts.next()?.to_string(),
+            password: parts.next()?.to_string(),
+            admins: parts.next()?.to_string(),
+            members: parts.next()?.to_string(),
+        };
+        Some(entry)
+    }
+
+    /// Serialize entry to writer, as a gshadow line.
+    pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> {
+        std::writeln!(
+            writer,
+            "{}:{}:{}:{}",
+            self.name,
+            self.password,
+            self.admins,
+            self.members,
+        )
+        .with_context(|| "failed to write gshadow entry")
+    }
+}
+
+pub fn parse_gshadow_content(content: impl BufRead) -> Result<Vec<GshadowEntry>> {
+    let mut entries = vec![];
+    for (line_num, line) in content.lines().enumerate() {
+        let input =
+            line.with_context(|| format!("failed to read gshadow entry at line {line_num}"))?;
+
+        // Skip empty and comment lines
+        if input.is_empty() || input.starts_with('#') {
+            continue;
+        }
+
+        let entry = GshadowEntry::parse_line(&input).ok_or_else(|| {
+            anyhow!(
+                "failed to parse gshadow entry at line {}, content: {}",
+                line_num,
+                &input
+            )
+        })?;
+        entries.push(entry);
+    }
+    Ok(entries)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::Cursor;
+
+    fn mock_gshadow_entry() -> GshadowEntry {
+        GshadowEntry {
+            name: "wheel".to_string(),
+            password: "!".to_string(),
+            admins: "admin1".to_string(),
+            members: "user1,user2".to_string(),
+        }
+    }
+
+    #[test]
+    fn test_parse_lines() {
+        let content = r#"
+root:*::
+daemon:*::
+
+# Dummy comment
+wheel:!:admin1:user1,user2
+plocate:!::
+"#;
+        let input = Cursor::new(content);
+        let entries = parse_gshadow_content(input).unwrap();
+        assert_eq!(entries.len(), 4);
+        assert_eq!(entries[2], mock_gshadow_entry());
+    }
+
+    #[test]
+    fn test_write_entry() {
+        let entry = mock_gshadow_entry();
+        let expected = b"wheel:!:admin1:user1,user2\n";
+        let mut buf = Vec::new();
+        entry.to_writer(&mut buf).unwrap();
+        assert_eq!(&buf, expected);
+    }
+
+    #[test]
+    fn test_duplicate_detection() {
+        let content = "plocate:!::\nplocate:!::\nwheel:!::\n";
+        let input = Cursor::new(content);
+        let entries = parse_gshadow_content(input).unwrap();
+        assert_eq!(entries.len(), 3);
+        // Verify we can detect duplicates
+        let mut seen = std::collections::HashSet::new();
+        let has_dups = entries.iter().any(|e| !seen.insert(&e.name));
+        assert!(has_dups);
+    }
+}
diff --git a/crates/sysusers/src/nameservice/mod.rs b/crates/sysusers/src/nameservice/mod.rs
index 14386251ae..13f89b2038 100644
--- a/crates/sysusers/src/nameservice/mod.rs
+++ b/crates/sysusers/src/nameservice/mod.rs
@@ -2,6 +2,7 @@
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 // TODO(lucab): consider moving this to its own crate.
 
-pub(crate) mod group;
-pub(crate) mod passwd;
-pub(crate) mod shadow;
+pub mod group;
+pub mod gshadow;
+pub mod passwd;
+pub mod shadow;
diff --git a/crates/sysusers/src/nameservice/passwd.rs b/crates/sysusers/src/nameservice/passwd.rs
index 3464df8311..2b41bd6094 100644
--- a/crates/sysusers/src/nameservice/passwd.rs
+++ b/crates/sysusers/src/nameservice/passwd.rs
@@ -7,14 +7,14 @@ use std::io::{BufRead, BufReader, Write};
 
 // Entry from passwd file.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct PasswdEntry {
-    pub(crate) name: String,
-    pub(crate) passwd: String,
-    pub(crate) uid: u32,
-    pub(crate) gid: u32,
-    pub(crate) gecos: String,
-    pub(crate) home_dir: String,
-    pub(crate) shell: String,
+pub struct PasswdEntry {
+    pub name: String,
+    pub passwd: String,
+    pub uid: u32,
+    pub gid: u32,
+    pub gecos: String,
+    pub home_dir: String,
+    pub shell: String,
 }
 
 impl PasswdEntry {
@@ -50,7 +50,7 @@ impl PasswdEntry {
     }
 }
 
-pub(crate) fn parse_passwd_content(content: impl BufRead) -> Result<Vec<PasswdEntry>> {
+pub fn parse_passwd_content(content: impl BufRead) -> Result<Vec<PasswdEntry>> {
     let mut passwds = vec![];
     for (line_num, line) in content.lines().enumerate() {
         let input =
@@ -78,7 +78,7 @@ pub(crate) fn parse_passwd_content(content: impl BufRead) -> Result<Vec<PasswdEn
     Ok(passwds)
 }
 
-pub(crate) fn load_etc_passwd(rootfs: &Dir) -> Result<Option<Vec<PasswdEntry>>> {
+pub fn load_etc_passwd(rootfs: &Dir) -> Result<Option<Vec<PasswdEntry>>> {
     if let Some(r) = rootfs.open_optional("etc/passwd")? {
         parse_passwd_content(BufReader::new(r)).map(Some)
     } else {
diff --git a/crates/sysusers/src/nameservice/shadow.rs b/crates/sysusers/src/nameservice/shadow.rs
index 44a62a98a2..f5293a0d9c 100644
--- a/crates/sysusers/src/nameservice/shadow.rs
+++ b/crates/sysusers/src/nameservice/shadow.rs
@@ -9,25 +9,25 @@ use std::io::{BufRead, Write};
 // Field names taken from (presumably glibc's) /usr/include/shadow.h, descriptions adapted
 // from the [shadow(3) manual page](https://man7.org/linux/man-pages/man3/shadow.3.html).
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct ShadowEntry {
+pub struct ShadowEntry {
     /// user login name
-    pub(crate) namp: String,
+    pub namp: String,
     /// encrypted password
-    pub(crate) pwdp: String,
+    pub pwdp: String,
     /// days (from Jan 1, 1970) since password was last changed
-    pub(crate) lstchg: Option<u32>,
+    pub lstchg: Option<u32>,
     /// days before which password may not be changed
-    pub(crate) min: Option<u32>,
+    pub min: Option<u32>,
     /// days after which password must be changed
-    pub(crate) max: Option<u32>,
+    pub max: Option<u32>,
     /// days before password is to expire that user is warned of pending password expiration
-    pub(crate) warn: Option<u32>,
+    pub warn: Option<u32>,
     /// days after password expires that account is considered inactive and disabled
-    pub(crate) inact: Option<u32>,
+    pub inact: Option<u32>,
     /// date (in days since Jan 1, 1970) when account will be disabled
-    pub(crate) expire: Option<u32>,
+    pub expire: Option<u32>,
     /// reserved for future use
-    pub(crate) flag: String,
+    pub flag: String,
 }
 
 fn u32_or_none(value: &str) -> Result<Option<u32>, std::num::ParseIntError> {
@@ -84,7 +84,7 @@ impl ShadowEntry {
     }
 }
 
-pub(crate) fn parse_shadow_content(content: impl BufRead) -> Result<Vec<ShadowEntry>> {
+pub fn parse_shadow_content(content: impl BufRead) -> Result<Vec<ShadowEntry>> {
     let mut entries = vec![];
     for (line_num, line) in content.lines().enumerate() {
         let input =
diff --git a/docs/src/man/bootc-sysusers-shadow-sync.service.5.md b/docs/src/man/bootc-sysusers-shadow-sync.service.5.md
new file mode 100644
index 0000000000..05f6e1ef40
--- /dev/null
+++ b/docs/src/man/bootc-sysusers-shadow-sync.service.5.md
@@ -0,0 +1,26 @@
+# NAME
+
+bootc-sysusers-shadow-sync.service
+
+# DESCRIPTION
+
+This systemd service removes orphaned and duplicate entries from
+`/etc/shadow` and `/etc/gshadow` before `systemd-sysusers.service` runs.
+
+If users or groups are dropped out from pristine `/etc` or `/usr/lib`
+copies of `passwd` or `group`, and they have systemd-sysusers entries,
+it's possible that stale data in the shadow files will cause systemd-sysusers
+to fail.
+
+This service runs before systemd-sysusers.service, and trims stale
+data.
+
+The service is only enabled on ostree and composefs boots; it has no
+effect on conventional package-managed systems.
+# SEE ALSO
+
+**bootc**(8), **bootc-systemd-generator**(8), **systemd-sysusers.service**(8), **lckpwdf**(3)
+
+# VERSION
+
+<!-- VERSION PLACEHOLDER -->
diff --git a/systemd/bootc-sysusers-shadow-sync.service b/systemd/bootc-sysusers-shadow-sync.service
new file mode 100644
index 0000000000..6c8453187b
--- /dev/null
+++ b/systemd/bootc-sysusers-shadow-sync.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Sync /etc/shadow and /etc/gshadow before systemd-sysusers
+Documentation=man:bootc-sysusers-shadow-sync.service(5)
+DefaultDependencies=no
+Before=systemd-sysusers.service
+After=systemd-remount-fs.service
+After=systemd-tmpfiles-setup-dev-early.service
+ConditionPathIsReadWrite=/etc
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/bin/bootc internals sysusers-sync
diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf
index 0997dde210..b3366648bf 100644
--- a/tmt/plans/integration.fmf
+++ b/tmt/plans/integration.fmf
@@ -269,4 +269,11 @@ execute:
     how: fmf
     test:
       - /tmt/tests/tests/test-43-switch-same-digest
+
+/plan-44-shadow-fixup:
+  summary: Test bootc-sysusers-shadow-sync removes orphaned gshadow entries before sysusers
+  discover:
+    how: fmf
+    test:
+      - /tmt/tests/tests/test-44-shadow-fixup
 # END GENERATED PLANS
diff --git a/tmt/tests/booted/test-44-shadow-fixup.nu b/tmt/tests/booted/test-44-shadow-fixup.nu
new file mode 100644
index 0000000000..fc73c438f0
--- /dev/null
+++ b/tmt/tests/booted/test-44-shadow-fixup.nu
@@ -0,0 +1,100 @@
+# number: 44
+# tmt:
+#   summary: Test bootc-sysusers-shadow-sync removes orphaned gshadow entries before sysusers
+#   duration: 30m
+#
+# Reproduces the stale-shadow problem: a gshadow entry exists for a group that
+# is absent from /etc/group and /usr/lib/group.  sysusers tries to create the
+# group from its sysusers.d definition and hits the stale gshadow entry.
+#
+# Two boots, one image build.  The stale entry is injected on the live running
+# system (not in the image), matching the real rechunk/rebase scenario where
+# rechunk resets /etc/group but the writable /etc overlay retains stale gshadow
+# entries from prior sysusers runs.
+#
+#   Boot 0 (initial_build):
+#     - Inject testbootcgroup:!:: into the live /etc/gshadow (writable overlay).
+#       This simulates what rechunk leaves behind after resetting /etc/group.
+#     - Build Image B: adds sysusers.d entry for testbootcgroup so sysusers will
+#       try to create the group on boot.  Image B's /etc/gshadow is clean.
+#
+#   3-way merge (base→Image B) for /etc/gshadow:
+#     base (no entry) + local (stale entry) + new image (no entry)
+#     → local modification wins, stale entry persists into the new deployment.
+#   /etc/group: absent in base, local, and new image → still absent after merge.
+#
+#   Boot 1: bootc-sysusers-shadow-sync.service removes the stale gshadow entry,
+#   then sysusers creates testbootcgroup cleanly from its sysusers.d definition.
+#
+use std assert
+use tap.nu
+use bootc_testlib.nu
+
+# Image B: has a sysusers.d entry for testbootcgroup so that systemd-sysusers
+# will try to create the group on boot.  Crucially, /etc/gshadow is NOT touched
+# here — the stale entry lives only in the running system's writable /etc
+# overlay (injected below), which is the actual rechunk/rebase scenario.
+const DOCKERFILE_B = '
+FROM localhost/bootc as base
+
+RUN printf "g testbootcgroup 7373\n" > /usr/lib/sysusers.d/testbootcgroup.conf
+
+# Verify the image itself has no gshadow or group entry for testbootcgroup.
+# The stale gshadow entry is injected on the live system, not in the image.
+RUN ! grep -q testbootcgroup /etc/gshadow
+RUN ! grep -q testbootcgroup /etc/group
+'
+
+def initial_build [] {
+    tap begin "shadow fixup test"
+
+    bootc image copy-to-storage
+
+    # Inject the stale gshadow entry as a local modification on the running system.
+    # This simulates what rechunk/rebase leaves behind: a gshadow entry for a group
+    # that no longer appears in /etc/group or /usr/lib/group.
+    "testbootcgroup:!::\n" | save --append /etc/gshadow
+    print "Injected stale gshadow entry: testbootcgroup:!::"
+
+    # On UKI composefs the derived image needs a sealed UKI; make_uki_containerfile
+    # appends the necessary build stages when running on a UKI system.
+    (tap make_uki_containerfile $DOCKERFILE_B) | save --force Dockerfile
+    podman build -t localhost/bootc-shadow-fixup-b .
+
+    bootc switch --transport containers-storage localhost/bootc-shadow-fixup-b
+    bootc_testlib reboot
+}
+
+def second_boot [] {
+    # systemctl show -P ActiveState always exits 0 and prints a plain string.
+    let active_state = (^systemctl show -P ActiveState bootc-sysusers-shadow-sync.service | str trim)
+
+    # Always print the unit status (includes recent journal lines) for context.
+    do { ^systemctl status bootc-sysusers-shadow-sync.service } | complete | get stdout | print
+
+    assert ($active_state == "active") $"bootc-sysusers-shadow-sync.service not active: ($active_state)"
+
+    # The service must have logged removing the stale gshadow entry.
+    let journal = (^journalctl -u bootc-sysusers-shadow-sync.service -b 0 --no-pager)
+    assert ($journal | str contains "orphaned") (
+        $"bootc-sysusers-shadow-sync.service did not log removing orphaned entries;\njournal:\n($journal)"
+    )
+
+    # sysusers must have (re)created the group cleanly in /etc/group.
+    let group_lines = (open /etc/group | lines | where { |l| $l | str starts-with "testbootcgroup:" })
+    assert (($group_lines | length) == 1) $"expected exactly one testbootcgroup in /etc/group, got: ($group_lines)"
+
+    # Stale entry must be gone; sysusers wrote exactly one fresh gshadow entry.
+    let gshadow_lines = (open /etc/gshadow | lines | where { |l| $l | str starts-with "testbootcgroup:" })
+    assert (($gshadow_lines | length) == 1) $"expected exactly one testbootcgroup in /etc/gshadow, got: ($gshadow_lines)"
+
+    tap ok
+}
+
+def main [] {
+    match $env.TMT_REBOOT_COUNT? {
+        null | "0" => initial_build,
+        "1" => second_boot,
+        $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } },
+    }
+}
diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf
index 90e1e50fff..9c6b41713d 100644
--- a/tmt/tests/tests.fmf
+++ b/tmt/tests/tests.fmf
@@ -168,3 +168,8 @@ check:
   summary: Error on bootc switch to image with identical fs-verity digest
   duration: 10m
   test: nu booted/test-switch-same-digest.nu
+
+/test-44-shadow-fixup:
+  summary: Test bootc-sysusers-shadow-sync removes orphaned gshadow entries before sysusers
+  duration: 30m
+  test: nu booted/test-44-shadow-fixup.nu
