diff --git a/include/linux/landlock.h b/include/linux/landlock.h
new file mode 100644
index 000000000000..a091deee9ad7
--- /dev/null
+++ b/include/linux/landlock.h
@@ -0,0 +1,26 @@
+/* SPDX-License-Identifier: GPL-2.0-only */
+/*
+ * Landlock public helpers
+ *
+ * Copyright © 2025 Microsoft Corporation
+ */
+
+#include <linux/cred.h>
+#include <linux/sched.h>
+
+#ifdef CONFIG_SECURITY_LANDLOCK
+
+int landlock_read_domain_id(const struct cred *const cred,
+			    struct task_struct *const task, const bool last,
+			    u64 *const id);
+
+#else /* CONFIG_SECURITY_LANDLOCK */
+
+static inline int landlock_read_domain_id(const struct cred *const cred,
+					  struct task_struct *const task,
+					  const bool last, u64 *const id)
+{
+	return -EOPNOTSUPP;
+}
+
+#endif /* CONFIG_SECURITY_LANDLOCK */
diff --git a/security/landlock/Makefile b/security/landlock/Makefile
index 3160c2bdac1d..5db653a1717e 100644
--- a/security/landlock/Makefile
+++ b/security/landlock/Makefile
@@ -1,11 +1,17 @@
 obj-$(CONFIG_SECURITY_LANDLOCK) := landlock.o
 
-landlock-y := setup.o syscalls.o object.o ruleset.o \
-	cred.o task.o fs.o
+landlock-y := \
+	setup.o \
+	syscalls.o \
+	object.o \
+	ruleset.o \
+	cred.o \
+	task.o \
+	fs.o \
+	id.o
 
 landlock-$(CONFIG_INET) += net.o
 
 landlock-$(CONFIG_AUDIT) += \
-	id.o \
 	audit.o \
 	domain.o
diff --git a/security/landlock/domain.c b/security/landlock/domain.c
index 49ccb0f72e53..782cc4ef73e0 100644
--- a/security/landlock/domain.c
+++ b/security/landlock/domain.c
@@ -21,7 +21,6 @@
 #include "common.h"
 #include "domain.h"
 #include "fs.h"
-#include "id.h"
 
 #ifdef CONFIG_AUDIT
 
@@ -125,7 +124,6 @@ int landlock_init_hierarchy_log(struct landlock_hierarchy *const hierarchy)
 		return PTR_ERR(details);
 
 	hierarchy->details = details;
-	hierarchy->id = landlock_get_id_range(1);
 	hierarchy->log_status = LANDLOCK_LOG_PENDING;
 	hierarchy->quiet_subdomains = false;
 	hierarchy->log_cross_exec = false;
diff --git a/security/landlock/domain.h b/security/landlock/domain.h
index 06b213aa7579..3f678caddaff 100644
--- a/security/landlock/domain.h
+++ b/security/landlock/domain.h
@@ -78,6 +78,10 @@ struct landlock_hierarchy {
 	 * Landlock domain.
 	 */
 	struct landlock_hierarchy *parent;
+	/**
+	 * @id: Landlock domain ID, sets once at domain creation time.
+	 */
+	u64 id;
 	/**
 	 * @usage: Number of potential children domains plus their parent
 	 * domain.
@@ -96,10 +100,6 @@ struct landlock_hierarchy {
 	 * Masked (i.e. never logged) denials are still counted.
 	 */
 	atomic64_t num_denials;
-	/**
-	 * @id: Landlock domain ID, sets once at domain creation time.
-	 */
-	u64 id;
 	/**
 	 * @details: Information about the related domain.
 	 */
diff --git a/security/landlock/ruleset.c b/security/landlock/ruleset.c
index de41e8bde2e4..e42933361c32 100644
--- a/security/landlock/ruleset.c
+++ b/security/landlock/ruleset.c
@@ -25,6 +25,7 @@
 #include "access.h"
 #include "audit.h"
 #include "domain.h"
+#include "id.h"
 #include "limits.h"
 #include "object.h"
 #include "ruleset.h"
@@ -576,6 +577,7 @@ landlock_merge_ruleset(struct landlock_ruleset *const parent,
 	if (err)
 		return ERR_PTR(err);
 
+	new_dom->hierarchy->id = landlock_get_id_range(1);
 	return no_free_ptr(new_dom);
 }
 
diff --git a/security/landlock/syscalls.c b/security/landlock/syscalls.c
index 5709a53c4a09..060ac9d52527 100644
--- a/security/landlock/syscalls.c
+++ b/security/landlock/syscalls.c
@@ -16,6 +16,7 @@
 #include <linux/err.h>
 #include <linux/errno.h>
 #include <linux/fs.h>
+#include <linux/landlock.h>
 #include <linux/limits.h>
 #include <linux/mount.h>
 #include <linux/path.h>
@@ -538,3 +539,77 @@ SYSCALL_DEFINE2(landlock_restrict_self, const int, ruleset_fd, const __u32,
 
 	return commit_creds(new_cred);
 }
+
+/**
+ * landlock_read_domain_id - Read the domain ID of a task
+ *
+ * @cred: The credential used to check permission.
+ * @task: Task to read the domain ID from.
+ * @last: Either get the ID of the last or the closest nested domain relative
+ *        to @cred.
+ * @id: Returned domain ID on success.
+ *
+ * @cred is only allowed to get information about its nested domains.
+ *
+ * Any error returned by this function just translates to non-information in a
+ * PIDFD_GET_INFO's PIDFD_INFO_LANDLOCK_LAST_DOMAIN or
+ * PIDFD_INFO_LANDLOCK_FIRST_DOMAIN request.
+ *
+ * Returns: 0 on success, or -errno on error.
+ */
+int landlock_read_domain_id(const struct cred *const cred,
+			    struct task_struct *const task, const bool last,
+			    u64 *const id)
+{
+	const struct landlock_ruleset *cred_dom, *task_dom;
+	const struct landlock_hierarchy *walker, *prev;
+	bool is_allowed;
+
+	if (!is_initialized())
+		return -EOPNOTSUPP;
+
+	cred_dom = landlock_cred(cred)->domain;
+
+	guard(rcu)();
+	task_dom = landlock_get_task_domain(task);
+
+	/*
+	 * Domain introspection is denied to not leak information about the
+	 * current restrictions.  However, a task can open a pidfd to itself,
+	 * sandbox itself, and then read its domain ID from the previously
+	 * opened pidfd.  This is OK because the task already knows its
+	 * sandbox, which might not be the case for its children.
+	 *
+	 * For consistency wrt sandboxed and non-sandboxed tasks, any task
+	 * should get the same result when directly reading its own domain.  A
+	 * non-sandboxed task requesting to read another non-sandboxed task
+	 * should then also be denied (i.e. cred_dom == task_dom == NULL).
+	 */
+	if (cred_dom == task_dom)
+		return -EPERM;
+
+	if (!task_dom)
+		return -EPERM;
+
+	/* Reading properties of @cred's nested domains is allowed. */
+	is_allowed = !cred_dom;
+	prev = task_dom->hierarchy;
+	for (walker = prev; walker; walker = walker->parent) {
+		if (cred_dom && walker == cred_dom->hierarchy) {
+			is_allowed = true;
+			break;
+		}
+
+		prev = walker;
+	}
+
+	if (!is_allowed)
+		return -EPERM;
+
+	if (last)
+		*id = task_dom->hierarchy->id;
+	else
+		*id = prev->id;
+
+	return 0;
+}
