diff --git a/Cargo.toml b/Cargo.toml
index b21d6f34..e95a24ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -87,6 +87,7 @@ octorust = { version = "0.10", default-features = false }
 once_cell = { version = "1.20.1", default-features = false }
 proc-macro2 = { version = "1", default-features = false }
 proc-macro2-diagnostics = { version = "0.10.1", default-features = false }
+procfs = { version = "0.17.0", default-features = false }
 public-api = { version = "0.44.0", default-features = false }
 quote = { version = "1", default-features = false }
 rand = { version = "0.9", default-features = false }
diff --git a/aya/src/sys/bpf.rs b/aya/src/sys/bpf.rs
index 3d6d419f..a52164ed 100644
--- a/aya/src/sys/bpf.rs
+++ b/aya/src/sys/bpf.rs
@@ -27,7 +27,7 @@ use libc::{ENOENT, ENOSPC};
 use crate::{
     Btf, FEATURES, Pod, VerifierLogLevel,
     maps::{MapData, PerCpuValues},
-    programs::links::LinkRef,
+    programs::{ProgramType, links::LinkRef},
     sys::{Syscall, SyscallError, syscall},
     util::KernelVersion,
 };
@@ -706,7 +706,7 @@ pub(crate) fn bpf_btf_get_fd_by_id(id: u32) -> Result<crate::MockableFd, Syscall
 }
 
 pub(crate) fn is_prog_name_supported() -> bool {
-    with_trivial_prog(|attr| {
+    with_trivial_prog(ProgramType::TracePoint, |attr| {
         let u = unsafe { &mut attr.__bindgen_anon_3 };
         let name = c"aya_name_check";
         let name_bytes = name.to_bytes();
@@ -727,7 +727,7 @@ fn new_insn(code: u8, dst_reg: u8, src_reg: u8, offset: i16, imm: i32) -> bpf_in
     insn
 }
 
-fn with_trivial_prog<T, F>(op: F) -> T
+pub(super) fn with_trivial_prog<T, F>(program_type: ProgramType, op: F) -> T
 where
     F: FnOnce(&mut bpf_attr) -> T,
 {
@@ -743,14 +743,40 @@ where
 
     u.insn_cnt = insns.len() as u32;
     u.insns = insns.as_ptr() as u64;
-    u.prog_type = bpf_prog_type::BPF_PROG_TYPE_TRACEPOINT as u32;
+
+    // `bpf_prog_load_fixup_attach_type()` sets this for us for cgroup_sock and
+    // and sk_reuseport.
+    let expected_attach_type = match program_type {
+        ProgramType::CgroupSkb => Some(bpf_attach_type::BPF_CGROUP_INET_INGRESS),
+        ProgramType::CgroupSockAddr => Some(bpf_attach_type::BPF_CGROUP_INET4_BIND),
+        ProgramType::LircMode2 => Some(bpf_attach_type::BPF_LIRC_MODE2),
+        ProgramType::CgroupSockopt => Some(bpf_attach_type::BPF_CGROUP_GETSOCKOPT),
+        ProgramType::Tracing => Some(bpf_attach_type::BPF_TRACE_FENTRY),
+        ProgramType::Lsm => Some(bpf_attach_type::BPF_LSM_MAC),
+        ProgramType::SkLookup => Some(bpf_attach_type::BPF_SK_LOOKUP),
+        ProgramType::Netfilter => Some(bpf_attach_type::BPF_NETFILTER),
+        _ => None,
+    };
+
+    match program_type {
+        ProgramType::KProbe if KernelVersion::current().is_ok() => {
+            u.kern_version = KernelVersion::current().unwrap().code()
+        }
+        ProgramType::Syscall => u.prog_flags = aya_obj::generated::BPF_F_SLEEPABLE,
+        _ => {}
+    }
+
+    u.prog_type = program_type as u32;
+    if let Some(expected_attach_type) = expected_attach_type {
+        u.expected_attach_type = expected_attach_type as u32;
+    }
 
     op(&mut attr)
 }
 
 /// Tests whether `nr_map_ids` & `map_ids` fields in `bpf_prog_info` is available.
 pub(crate) fn is_info_map_ids_supported() -> bool {
-    with_trivial_prog(|attr| {
+    with_trivial_prog(ProgramType::TracePoint, |attr| {
         let prog_fd = match bpf_prog_load(attr) {
             Ok(fd) => fd,
             Err(_) => return false,
@@ -764,7 +790,7 @@ pub(crate) fn is_info_map_ids_supported() -> bool {
 
 /// Tests whether `gpl_compatible` field in `bpf_prog_info` is available.
 pub(crate) fn is_info_gpl_compatible_supported() -> bool {
-    with_trivial_prog(|attr| {
+    with_trivial_prog(ProgramType::TracePoint, |attr| {
         let prog_fd = match bpf_prog_load(attr) {
             Ok(fd) => fd,
             Err(_) => return false,
@@ -805,7 +831,7 @@ pub(crate) fn is_probe_read_kernel_supported() -> bool {
 }
 
 pub(crate) fn is_perf_link_supported() -> bool {
-    with_trivial_prog(|attr| {
+    with_trivial_prog(ProgramType::TracePoint, |attr| {
         if let Ok(fd) = bpf_prog_load(attr) {
             let fd = fd.as_fd();
             // Uses an invalid target FD so we get EBADF if supported.
@@ -1073,7 +1099,7 @@ pub(crate) fn is_btf_type_tag_supported() -> bool {
     bpf_load_btf(btf_bytes.as_slice(), &mut [], Default::default()).is_ok()
 }
 
-fn bpf_prog_load(attr: &mut bpf_attr) -> io::Result<crate::MockableFd> {
+pub(super) fn bpf_prog_load(attr: &mut bpf_attr) -> io::Result<crate::MockableFd> {
     // SAFETY: BPF_PROG_LOAD returns a new file descriptor.
     unsafe { fd_sys_bpf(bpf_cmd::BPF_PROG_LOAD, attr) }
 }
diff --git a/aya/src/sys/feature_probe.rs b/aya/src/sys/feature_probe.rs
new file mode 100644
index 00000000..e60ecd28
--- /dev/null
+++ b/aya/src/sys/feature_probe.rs
@@ -0,0 +1,98 @@
+//! Probes and identifies available eBPF features supported by the host kernel.
+
+use aya_obj::btf::{Btf, BtfKind};
+use libc::{E2BIG, EINVAL};
+
+use super::{SyscallError, bpf_prog_load, with_trivial_prog};
+use crate::programs::{ProgramError, ProgramType};
+
+/// Whether the host kernel supports the [`ProgramType`].
+///
+/// # Examples
+///
+/// ```no_run
+/// # use aya::{
+/// #     programs::ProgramType,
+/// #     sys::feature_probe::is_program_supported,
+/// # };
+/// #
+/// match is_program_supported(ProgramType::Xdp) {
+///     Ok(true) => println!("XDP supported :)"),
+///     Ok(false) => println!("XDP not supported :("),
+///     Err(err) => println!("Uh oh! Unexpected error: {:?}", err),
+/// }
+/// ```
+///
+/// # Errors
+///
+/// Returns [`ProgramError::SyscallError`] if a syscall fails with an unexpected
+/// error, or [`ProgramError::Btf`] for BTF related errors.
+///
+/// Certain errors are expected and handled internally; only unanticipated
+/// failures during probing will result in these errors.
+pub fn is_program_supported(program_type: ProgramType) -> Result<bool, ProgramError> {
+    if program_type == ProgramType::Unspecified {
+        return Ok(false);
+    }
+
+    let mut verifier_log = [0_u8; libc::PATH_MAX as usize];
+    let attach_btf_id = if matches!(program_type, ProgramType::Tracing | ProgramType::Lsm) {
+        let func_name = if program_type == ProgramType::Tracing {
+            "bpf_fentry_test1"
+        } else {
+            "bpf_lsm_bpf"
+        };
+        Btf::from_sys_fs()
+            .and_then(|btf| btf.id_by_type_name_kind(func_name, BtfKind::Func))
+            .unwrap_or(0)
+    } else {
+        0
+    };
+
+    let error = match with_trivial_prog(program_type, |attr| {
+        // SAFETY: union access
+        let u = unsafe { &mut attr.__bindgen_anon_3 };
+
+        u.attach_btf_id = attach_btf_id;
+        match program_type {
+            ProgramType::Tracing | ProgramType::Extension | ProgramType::Lsm => {
+                u.log_buf = verifier_log.as_mut_ptr() as u64;
+                u.log_level = 1;
+                u.log_size = verifier_log.len() as u32;
+            }
+            _ => {}
+        }
+
+        bpf_prog_load(attr).err().map(|io_error| {
+            ProgramError::SyscallError(SyscallError {
+                call: "bpf_prog_load",
+                io_error,
+            })
+        })
+    }) {
+        Some(err) => err,
+        None => return Ok(true),
+    };
+
+    match &error {
+        ProgramError::SyscallError(err) => {
+            match err.io_error.raw_os_error() {
+                Some(EINVAL) => {
+                    // verifier/`bpf_check_attach_target()` (or `check_attach_btf_id()` on older
+                    // kernels) produces this log message for these prog types if `attach_btf_id`
+                    // is unset
+                    let supported = matches!(
+                        program_type,
+                        ProgramType::Tracing | ProgramType::Extension | ProgramType::Lsm
+                            if verifier_log.starts_with(b"Tracing programs must provide btf_id"));
+                    Ok(supported)
+                }
+                Some(E2BIG) => Ok(false),
+                // `ENOTSUPP` from verifier/`check_struct_ops_btf_id()` for struct_ops
+                Some(524) if program_type == ProgramType::StructOps => Ok(true),
+                _ => Err(error),
+            }
+        }
+        _ => Err(error),
+    }
+}
diff --git a/aya/src/sys/mod.rs b/aya/src/sys/mod.rs
index f8c8944b..00d3fbdd 100644
--- a/aya/src/sys/mod.rs
+++ b/aya/src/sys/mod.rs
@@ -1,6 +1,7 @@
 //! A collection of system calls for performing eBPF related operations.
 
 mod bpf;
+pub mod feature_probe;
 mod netlink;
 mod perf_event;
 
diff --git a/test/integration-test/Cargo.toml b/test/integration-test/Cargo.toml
index 162fc82c..f908c4ea 100644
--- a/test/integration-test/Cargo.toml
+++ b/test/integration-test/Cargo.toml
@@ -27,6 +27,7 @@ libc = { workspace = true }
 log = { workspace = true }
 netns-rs = { workspace = true }
 object = { workspace = true, features = ["elf", "read_core", "std"] }
+procfs = { workspace = true, features = ["flate2"] }
 rand = { workspace = true, features = ["thread_rng"] }
 rbpf = { workspace = true }
 scopeguard = { workspace = true }
diff --git a/test/integration-test/src/tests.rs b/test/integration-test/src/tests.rs
index 9ca83669..43894366 100644
--- a/test/integration-test/src/tests.rs
+++ b/test/integration-test/src/tests.rs
@@ -1,6 +1,7 @@
 mod bpf_probe_read;
 mod btf_relocations;
 mod elf;
+mod feature_probe;
 mod info;
 mod iter;
 mod load;
diff --git a/test/integration-test/src/tests/feature_probe.rs b/test/integration-test/src/tests/feature_probe.rs
new file mode 100644
index 00000000..c1bd0d50
--- /dev/null
+++ b/test/integration-test/src/tests/feature_probe.rs
@@ -0,0 +1,212 @@
+//! Test feature probing against kernel version.
+
+use assert_matches::assert_matches;
+use aya::{Btf, programs::ProgramType, sys::feature_probe::*, util::KernelVersion};
+use procfs::kernel_config;
+
+#[test]
+fn probe_supported_programs() {
+    let current = KernelVersion::current().unwrap();
+    let kernel_config = kernel_config().unwrap_or_default();
+
+    let socket_filter = is_program_supported(ProgramType::SocketFilter);
+    if current >= KernelVersion::new(3, 19, 0) {
+        assert_matches!(socket_filter, Ok(true));
+    } else {
+        assert_matches!(socket_filter, Ok(false));
+    }
+
+    let kprobe = is_program_supported(ProgramType::KProbe);
+    let sched_cls = is_program_supported(ProgramType::SchedClassifier);
+    let sched_act = is_program_supported(ProgramType::SchedAction);
+    if current >= KernelVersion::new(4, 1, 0) {
+        assert_matches!(kprobe, Ok(true));
+        assert_matches!(sched_cls, Ok(true));
+        assert_matches!(sched_act, Ok(true));
+    } else {
+        assert_matches!(kprobe, Ok(false));
+        assert_matches!(sched_cls, Ok(false));
+        assert_matches!(sched_act, Ok(false));
+    }
+
+    let tracepoint = is_program_supported(ProgramType::TracePoint);
+    if current >= KernelVersion::new(4, 7, 0) {
+        assert_matches!(tracepoint, Ok(true));
+    } else {
+        assert_matches!(tracepoint, Ok(false));
+    }
+
+    let xdp = is_program_supported(ProgramType::Xdp);
+    if current >= KernelVersion::new(4, 8, 0) {
+        assert_matches!(xdp, Ok(true));
+    } else {
+        assert_matches!(xdp, Ok(false));
+    }
+
+    let perf_event = is_program_supported(ProgramType::PerfEvent);
+    if current >= KernelVersion::new(4, 9, 0) {
+        assert_matches!(perf_event, Ok(true));
+    } else {
+        assert_matches!(perf_event, Ok(false));
+    }
+
+    let cgroup_skb = is_program_supported(ProgramType::CgroupSkb);
+    let cgroup_sock = is_program_supported(ProgramType::CgroupSock);
+    let lwt_in = is_program_supported(ProgramType::LwtInput);
+    let lwt_out = is_program_supported(ProgramType::LwtOutput);
+    let lwt_xmit = is_program_supported(ProgramType::LwtXmit);
+    if current >= KernelVersion::new(4, 10, 0) {
+        assert_matches!(cgroup_skb, Ok(true));
+        assert_matches!(cgroup_sock, Ok(true));
+        assert_matches!(lwt_in, Ok(true));
+        assert_matches!(lwt_out, Ok(true));
+        assert_matches!(lwt_xmit, Ok(true));
+    } else {
+        assert_matches!(cgroup_skb, Ok(false));
+        assert_matches!(cgroup_sock, Ok(false));
+        assert_matches!(lwt_in, Ok(false));
+        assert_matches!(lwt_out, Ok(false));
+        assert_matches!(lwt_xmit, Ok(false));
+    }
+
+    let sock_ops = is_program_supported(ProgramType::SockOps);
+    if current >= KernelVersion::new(4, 13, 0) {
+        assert_matches!(sock_ops, Ok(true));
+    } else {
+        assert_matches!(sock_ops, Ok(false));
+    }
+
+    let sk_skb = is_program_supported(ProgramType::SkSkb);
+    if current >= KernelVersion::new(4, 14, 0) {
+        assert_matches!(sk_skb, Ok(true));
+    } else {
+        assert_matches!(sk_skb, Ok(false));
+    }
+
+    let cgroup_device = is_program_supported(ProgramType::CgroupDevice);
+    if current >= KernelVersion::new(4, 15, 0) {
+        assert_matches!(cgroup_device, Ok(true));
+    } else {
+        assert_matches!(cgroup_device, Ok(false));
+    }
+
+    let sk_msg = is_program_supported(ProgramType::SkMsg);
+    let raw_tp = is_program_supported(ProgramType::RawTracePoint);
+    let cgroup_sock_addr = is_program_supported(ProgramType::CgroupSockAddr);
+    if current >= KernelVersion::new(4, 17, 0) {
+        assert_matches!(sk_msg, Ok(true));
+        assert_matches!(raw_tp, Ok(true));
+        assert_matches!(cgroup_sock_addr, Ok(true));
+    } else {
+        assert_matches!(sk_msg, Ok(false));
+        assert_matches!(raw_tp, Ok(false));
+        assert_matches!(cgroup_sock_addr, Ok(false));
+    }
+
+    let lwt_seg6local = is_program_supported(ProgramType::LwtSeg6local);
+    let lirc_mode2 = is_program_supported(ProgramType::LircMode2); // Requires CONFIG_BPF_LIRC_MODE2=y
+    if current >= KernelVersion::new(4, 18, 0) {
+        assert_matches!(lwt_seg6local, Ok(true));
+
+        let lirc_mode2_config = matches!(
+            kernel_config.get("CONFIG_BPF_LIRC_MODE2"),
+            Some(procfs::ConfigSetting::Yes)
+        );
+        assert_matches!(lirc_mode2, Ok(lirc_mode2) if lirc_mode2 == lirc_mode2_config);
+        if !lirc_mode2_config {
+            eprintln!("CONFIG_BPF_LIRC_MODE2 required for lirc_mode2 program type");
+        }
+    } else {
+        assert_matches!(lwt_seg6local, Ok(false));
+        assert_matches!(lirc_mode2, Ok(false));
+    }
+
+    let sk_reuseport = is_program_supported(ProgramType::SkReuseport);
+    if current >= KernelVersion::new(4, 19, 0) {
+        assert_matches!(sk_reuseport, Ok(true));
+    } else {
+        assert_matches!(sk_reuseport, Ok(false));
+    }
+
+    let flow_dissector = is_program_supported(ProgramType::FlowDissector);
+    if current >= KernelVersion::new(4, 20, 0) {
+        assert_matches!(flow_dissector, Ok(true));
+    } else {
+        assert_matches!(flow_dissector, Ok(false));
+    }
+
+    let cgroup_sysctl = is_program_supported(ProgramType::CgroupSysctl);
+    let raw_tp_writable = is_program_supported(ProgramType::RawTracePointWritable);
+    if current >= KernelVersion::new(5, 2, 0) {
+        assert_matches!(cgroup_sysctl, Ok(true));
+        assert_matches!(raw_tp_writable, Ok(true));
+    } else {
+        assert_matches!(cgroup_sysctl, Ok(false));
+        assert_matches!(raw_tp_writable, Ok(false));
+    }
+
+    let cgroup_sockopt = is_program_supported(ProgramType::CgroupSockopt);
+    if current >= KernelVersion::new(5, 3, 0) {
+        assert_matches!(cgroup_sockopt, Ok(true));
+    } else {
+        assert_matches!(cgroup_sockopt, Ok(false));
+    }
+
+    let tracing = is_program_supported(ProgramType::Tracing); // Requires `CONFIG_DEBUG_INFO_BTF=y`
+    if current >= KernelVersion::new(5, 5, 0) {
+        assert_matches!(tracing, Ok(true));
+    } else {
+        assert_matches!(tracing, Ok(false));
+    }
+
+    let struct_ops = is_program_supported(ProgramType::StructOps);
+    let extension = is_program_supported(ProgramType::Extension);
+    if current >= KernelVersion::new(5, 6, 0) {
+        assert_matches!(struct_ops, Ok(true));
+        assert_matches!(extension, Ok(true));
+    } else {
+        assert_matches!(struct_ops, Ok(false));
+        assert_matches!(extension, Ok(false));
+    }
+
+    let lsm = is_program_supported(ProgramType::Lsm); // Requires `CONFIG_DEBUG_INFO_BTF=y` & `CONFIG_BPF_LSM=y`
+    if current >= KernelVersion::new(5, 7, 0) {
+        // Ways to check if `CONFIG_BPF_LSM` is enabled:
+        // - kernel config has `CONFIG_BPF_LSM=y`, but config is not always exposed.
+        // - an LSM hooks is present in BTF, e.g. `bpf_lsm_bpf`. hooks are found in `bpf_lsm.c`
+        let lsm_enabled = matches!(
+            kernel_config.get("CONFIG_BPF_LSM"),
+            Some(procfs::ConfigSetting::Yes)
+        ) || Btf::from_sys_fs()
+            .and_then(|btf| btf.id_by_type_name_kind("bpf_lsm_bpf", aya_obj::btf::BtfKind::Func))
+            .is_ok();
+
+        assert_matches!(lsm, Ok(lsm_supported) if lsm_supported == lsm_enabled);
+        if !lsm_enabled {
+            eprintln!("CONFIG_BPF_LSM required for lsm program type");
+        }
+    } else {
+        assert_matches!(lsm, Ok(false));
+    }
+
+    let sk_lookup = is_program_supported(ProgramType::SkLookup);
+    if current >= KernelVersion::new(5, 9, 0) {
+        assert_matches!(sk_lookup, Ok(true));
+    } else {
+        assert_matches!(sk_lookup, Ok(false));
+    }
+
+    let syscall = is_program_supported(ProgramType::Syscall);
+    if current >= KernelVersion::new(5, 14, 0) {
+        assert_matches!(syscall, Ok(true));
+    } else {
+        assert_matches!(syscall, Ok(false));
+    }
+
+    let netfilter = is_program_supported(ProgramType::Netfilter);
+    if current >= KernelVersion::new(6, 4, 0) {
+        assert_matches!(netfilter, Ok(true));
+    } else {
+        assert_matches!(netfilter, Ok(false));
+    }
+}
diff --git a/test/integration-test/src/tests/load.rs b/test/integration-test/src/tests/load.rs
index a93e6f16..85268ab9 100644
--- a/test/integration-test/src/tests/load.rs
+++ b/test/integration-test/src/tests/load.rs
@@ -14,7 +14,7 @@ use aya_obj::programs::XdpAttachType;
 use test_log::test;
 
 const MAX_RETRIES: usize = 100;
-const RETRY_DURATION: Duration = Duration::from_millis(10);
+pub(crate) const RETRY_DURATION: Duration = Duration::from_millis(10);
 
 #[test]
 fn long_name() {
diff --git a/xtask/public-api/aya.txt b/xtask/public-api/aya.txt
index 94a70d3c..92d8ff60 100644
--- a/xtask/public-api/aya.txt
+++ b/xtask/public-api/aya.txt
@@ -9997,6 +9997,8 @@ impl aya::programs::MultiProgram for aya::programs::tc::SchedClassifier
 pub fn aya::programs::tc::SchedClassifier::fd(&self) -> core::result::Result<std::os::fd::owned::BorrowedFd<'_>, aya::programs::ProgramError>
 pub fn aya::programs::loaded_programs() -> impl core::iter::traits::iterator::Iterator<Item = core::result::Result<aya::programs::ProgramInfo, aya::programs::ProgramError>>
 pub mod aya::sys
+pub mod aya::sys::feature_probe
+pub fn aya::sys::feature_probe::is_program_supported(program_type: aya::programs::ProgramType) -> core::result::Result<bool, aya::programs::ProgramError>
 #[non_exhaustive] pub enum aya::sys::Stats
 pub aya::sys::Stats::RunTime
 impl core::clone::Clone for aya::sys::Stats