diff --git a/pressure-vessel/meson.build b/pressure-vessel/meson.build
index 245e313c7941edfda395a04a39c0e40fdb6dc808..5bb38e9cfb0a91f4070727211b76daa907dc846c 100644
--- a/pressure-vessel/meson.build
+++ b/pressure-vessel/meson.build
@@ -171,6 +171,8 @@ pressure_vessel_wrap_lib = static_library(
     'flatpak-run-x11.c',
     'graphics-provider.c',
     'graphics-provider.h',
+    'passwd.c',
+    'passwd.h',
     'runtime.c',
     'runtime.h',
     'wrap-context.c',
diff --git a/pressure-vessel/passwd.c b/pressure-vessel/passwd.c
new file mode 100644
index 0000000000000000000000000000000000000000..f17e48e803b3847f3a00efc7bcc2d507cf69f454
--- /dev/null
+++ b/pressure-vessel/passwd.c
@@ -0,0 +1,263 @@
+/*
+ * Copyright © 2020-2024 Collabora Ltd.
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include "passwd.h"
+
+#include "steam-runtime-tools/log-internal.h"
+
+/* Append @field to @buffer, replacing colons or newlines with '_'
+ * to avoid possibly corrupting the passwd(5)/group(5) syntax. */
+static void
+append_field (GString *buffer,
+              const char *field)
+{
+  gsize len = strlen (field);
+  gsize old_len = buffer->len;
+  gboolean warned = FALSE;
+  gsize i;
+
+  g_string_append (buffer, field);
+
+  for (i = 0; i < len; i++)
+    {
+      switch (buffer->str[old_len + i])
+        {
+          case ':':
+          case '\n':
+            if (!warned)
+              {
+                _srt_log_warning ("Field \"%s\" cannot be represented in passwd(5)/group(5)",
+                                  field);
+                warned = TRUE;
+              }
+
+            buffer->str[old_len + i] = '_';
+            break;
+
+          default:
+            break;
+        }
+    }
+
+  g_assert (buffer->str[old_len + len] == '\0');
+}
+
+/*
+ * Assume that the first line in @buffer is a single user in passwd(5)
+ * syntax or a single group in group(5) syntax.
+ *
+ * Append all lines from @source/@path to @buffer, unless they would
+ * duplicate the user/group that is already there.
+ */
+static gboolean
+append_remaining_lines (GString *buffer,
+                        SrtSysroot *source,
+                        const char *path,
+                        GError **error)
+{
+  g_autofree gchar *exclude_same_name = NULL;
+  g_autofree gchar *content = NULL;
+  const char *line;
+  gsize line_num;
+
+  if (buffer->len != 0)
+    {
+      const char *colon;
+
+      colon = strchr (buffer->str, ':');
+      g_assert (colon != NULL);
+      /* username + ':' as a prefix to exclude matching lines, e.g. "gfreeman:" */
+      exclude_same_name = g_strndup (buffer->str, (colon - buffer->str) + 1);
+    }
+
+  if (!_srt_sysroot_load (source, path, SRT_RESOLVE_FLAGS_READABLE,
+                          NULL, &content, NULL, error))
+    return FALSE;
+
+  line = content;
+  line_num = 0;
+
+  while (TRUE)
+    {
+      const char *end_of_line;
+      gsize len;
+
+      end_of_line = strchr (line, '\n');
+
+      if (end_of_line == NULL)
+        len = strlen (line);
+      else
+        len = end_of_line - line;
+
+      if (exclude_same_name != NULL && g_str_has_prefix (line, exclude_same_name))
+        {
+          g_debug ("Skipping %s:%zu \"%s...\" because it is our user/group",
+                   path, line_num, exclude_same_name);
+        }
+      else if (len > 0)
+        {
+          g_string_append_len (buffer, line, len);
+          g_string_append_c (buffer, '\n');
+        }
+
+      if (end_of_line == NULL)
+        break;
+
+      line = end_of_line + 1;
+      line_num++;
+    }
+
+  return TRUE;
+}
+
+/* getpwuid(), but with mock output for unit-testing */
+static const struct passwd *
+getpwuid_wrapper (uid_t uid,
+                  PvMockPasswdLookup *mock)
+{
+  if (G_LIKELY (mock == NULL))
+    return getpwuid (uid);
+
+  g_assert_cmpint (mock->uid, ==, uid);
+
+  if (mock->lookup_errno != 0)
+    errno = mock->lookup_errno;
+
+  return mock->pwd;
+}
+
+/*
+ * @source: A sysroot from which we can read /etc/passwd
+ * @mock: (allow-none): A mock version of the real getpwuid() result,
+ *  used during unit-testing
+ *
+ * Return contents for a passwd(5) that has at least our own uid.
+ */
+gchar *
+pv_generate_etc_passwd (SrtSysroot *source,
+                        PvMockPasswdLookup *mock)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(GString) buffer = NULL;
+  struct passwd fallback =
+    {
+      .pw_name = NULL,
+      .pw_passwd = (char *) "x",
+      .pw_uid = -1,
+      .pw_gid = -1,
+      .pw_gecos = NULL,
+      .pw_dir = NULL,
+      .pw_shell = (char *) "/bin/bash",
+    };
+  const struct passwd *pw = NULL;
+
+  fallback.pw_name = (char *) g_get_user_name ();
+
+  if (fallback.pw_name == NULL)
+    fallback.pw_name = (char *) "user";
+
+  fallback.pw_gecos = (char *) g_get_real_name ();
+
+  if (fallback.pw_gecos == NULL)
+    fallback.pw_gecos = fallback.pw_name;
+
+  fallback.pw_uid = getuid ();
+  fallback.pw_gid = getgid ();
+  fallback.pw_dir = (char *) g_get_home_dir ();
+
+  errno = 0;
+  pw = getpwuid_wrapper (fallback.pw_uid, mock);
+
+  if (pw == NULL)
+    {
+      int saved_errno = errno;
+
+      _srt_log_warning ("Unable to resolve uid %d: %s",
+                        fallback.pw_uid,
+                        saved_errno == 0 ? "user not found" : g_strerror (errno));
+      pw = &fallback;
+    }
+
+  g_assert (pw != NULL);
+  buffer = g_string_new ("");
+  append_field (buffer, pw->pw_name);
+  g_string_append_printf (buffer, ":x:%d:%d:", pw->pw_uid, pw->pw_gid);
+  append_field (buffer, pw->pw_gecos);
+  g_string_append_c (buffer, ':');
+  append_field (buffer, pw->pw_dir);
+  /* We always behave as if the user's shell is bash, because we can rely
+   * on that existing in the container, whereas an alternative shell like
+   * /bin/zsh might not. */
+  g_string_append (buffer, ":/bin/bash\n");
+
+  if (!append_remaining_lines (buffer, source, "/etc/passwd", &local_error))
+    {
+      _srt_log_warning ("%s", local_error->message);
+      g_clear_error (&local_error);
+    }
+
+  return g_string_free (g_steal_pointer (&buffer), FALSE);
+}
+
+/* getgrgid(), but with mock output for unit-testing */
+static const struct group *
+getgrgid_wrapper (gid_t gid,
+                  PvMockPasswdLookup *mock)
+{
+  if (G_LIKELY (mock == NULL))
+    return getgrgid (gid);
+
+  g_assert_cmpint (mock->gid, ==, gid);
+
+  if (mock->lookup_errno != 0)
+    errno = mock->lookup_errno;
+
+  return mock->grp;
+}
+
+/*
+ * @source: A sysroot from which we can read /etc/passwd
+ * @mock: (allow-none): A mock version of the real getgrgid() result,
+ *  used during unit-testing
+ *
+ * Return contents for a group(5) that has at least our own primary gid.
+ */
+gchar *
+pv_generate_etc_group (SrtSysroot *source,
+                       PvMockPasswdLookup *mock)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(GString) buffer = NULL;
+  gid_t primary_gid = getgid ();
+  const struct group *gr = NULL;
+
+  errno = 0;
+  gr = getgrgid_wrapper (primary_gid, mock);
+
+  if (gr == NULL)
+    {
+      int saved_errno = errno;
+
+      _srt_log_warning ("Unable to resolve gid %d: %s",
+                        primary_gid,
+                        saved_errno == 0 ? "group not found" : g_strerror (errno));
+    }
+
+  buffer = g_string_new ("");
+
+  if (gr != NULL)
+    {
+      append_field (buffer, gr->gr_name);
+      g_string_append_printf (buffer, ":x:%d:\n", gr->gr_gid);
+    }
+
+  if (!append_remaining_lines (buffer, source, "/etc/group", &local_error))
+    {
+      _srt_log_warning ("%s", local_error->message);
+      g_clear_error (&local_error);
+    }
+
+  return g_string_free (g_steal_pointer (&buffer), FALSE);
+}
diff --git a/pressure-vessel/passwd.h b/pressure-vessel/passwd.h
new file mode 100644
index 0000000000000000000000000000000000000000..d54b66e41f964e8f3bd9935f2806609b30fe5064
--- /dev/null
+++ b/pressure-vessel/passwd.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright © 2020-2024 Collabora Ltd.
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#pragma once
+
+#include <sys/types.h>
+#include <grp.h>
+#include <pwd.h>
+
+#include "steam-runtime-tools/glib-backports-internal.h"
+#include "steam-runtime-tools/resolve-in-sysroot-internal.h"
+
+/* Mock data to be used during unit-testing */
+typedef struct
+{
+  uid_t uid;
+  gid_t gid;
+  const struct passwd *pwd;
+  const struct group *grp;
+  int lookup_errno;
+} PvMockPasswdLookup;
+
+gchar *pv_generate_etc_passwd (SrtSysroot *source,
+                               PvMockPasswdLookup *mock);
+gchar *pv_generate_etc_group (SrtSysroot *source,
+                              PvMockPasswdLookup *mock);
diff --git a/pressure-vessel/runtime.c b/pressure-vessel/runtime.c
index ac9df5997f8719010d7451ee60f6ad59ebc73938..ef0f5881975b4fda71808d54a0c1d519d341fd58 100644
--- a/pressure-vessel/runtime.c
+++ b/pressure-vessel/runtime.c
@@ -46,6 +46,7 @@
 #include "exports.h"
 #include "flatpak-run-private.h"
 #include "mtree.h"
+#include "passwd.h"
 #include "supported-architectures.h"
 #include "tree-copy.h"
 #include "utils.h"
@@ -3209,10 +3210,6 @@ bind_runtime_base (PvRuntime *self,
   };
   static const char * const from_host[] =
   {
-    /* TODO: Synthesize a passwd with only the user and nobody,
-     * like Flatpak does? */
-    "/etc/group",
-    "/etc/passwd",
     "/etc/host.conf",
     "/etc/hosts",
     "/etc/resolv.conf",
@@ -3496,6 +3493,32 @@ bind_runtime_base (PvRuntime *self,
         g_return_val_if_reached (FALSE);
     }
 
+    {
+      g_autofree gchar *content = pv_generate_etc_passwd (self->real_root, NULL);
+
+      g_assert (content != NULL);
+
+      if (!pv_runtime_bind_into_container (self, bwrap,
+                                           "etc-passwd", content, -1,
+                                           "/etc/passwd",
+                                           PV_RUNTIME_EMULATION_ROOTS_BOTH,
+                                           error))
+        return FALSE;
+    }
+
+    {
+      g_autofree gchar *content = pv_generate_etc_group (self->real_root, NULL);
+
+      g_assert (content != NULL);
+
+      if (!pv_runtime_bind_into_container (self, bwrap,
+                                           "etc-group", content, -1,
+                                           "/etc/group",
+                                           PV_RUNTIME_EMULATION_ROOTS_BOTH,
+                                           error))
+        return FALSE;
+    }
+
   if (self->provider != NULL)
     {
       for (i = 0; from_provider[i] != NULL; i++)