fapolicyd on Linux in 2026: Application Allowlisting on RHEL and Fedora Without Locking Yourself Out

A practical 2026 guide to fapolicyd on RHEL and Fedora: how it uses fanotify to gate execve, how to write rules that don't lock you out, the safe permissive-to-enforce rollout, and how it maps to CIS, STIG, and PCI DSS compliance.

Updated: August 18, 2026

fapolicyd is a Linux user-space daemon that enforces file-execution allowlisting by intercepting execve(), open(), and mmap() through the kernel's fanotify API, then consulting a trust database of RPM-signed binaries before allowing them to run. In practice, that gives you application allowlisting on RHEL 8/9/10 and Fedora with a fraction of the effort a full SELinux policy would take, and it's how CIS Benchmarks 2.x and DISA STIG V1R14 now expect you to satisfy "application execution restriction" controls. This guide covers how fapolicyd 1.3+ works in 2026, how to write rules that don't lock you out, and the compliance mapping I use in production.

  • fapolicyd uses fanotify (kernel 5.1+ permission events) to gate execution, so it operates at a layer below any userland shim and can't be bypassed by renaming a binary.
  • The trust database is seeded from RPM signatures via fapolicyd-cli --update; unknown or unsigned binaries are denied by default in enforce mode.
  • RHEL 10 ships fapolicyd 1.3.5 with the new rules.d/ layout and out-of-tree Python/Node interpreter mediation via the ftype subject.
  • fapolicyd complements SELinux. SELinux constrains what a process can touch; fapolicyd constrains whether a binary can execute at all.
  • Start in permissive = 1 for at least a week, harvest denials from /var/log/fapolicyd-access.log, then flip to enforce. It's honestly the only safe rollout path on production fleets.
  • Debian and Ubuntu have no first-class equivalent; the closest match is a combination of Landlock policies and IMA appraisal.

What is fapolicyd and how does it work?

fapolicyd (the File Access Policy Daemon) is a userspace service that decides, for every attempted execution on the host, whether the binary about to run is trusted. It plugs into the kernel through fanotify(7) in permission-event mode, which was stabilised in kernel 5.1 (May 2019) and refined for content policies in 5.16. When a process calls execve() or maps an executable page with mmap(PROT_EXEC), the kernel blocks the syscall and sends an FAN_OPEN_EXEC_PERM event to fapolicyd. The daemon looks up the file's SHA-256 in its trust database, consults /etc/fapolicyd/rules.d/, then returns FAN_ALLOW or FAN_DENY to the kernel, which then permits or aborts the syscall with EACCES.

Because the decision happens inside the fanotify permission callback, an attacker who copies /bin/bash to /tmp/pwn or drops a compiled implant into /dev/shm can't execute it. The SHA-256 does not appear in the RPM-derived trust database, and no matching rule allows it. This is materially different from PATH allowlisting or NoExec mount options, both of which an unprivileged attacker can trivially sidestep. fapolicyd's design goal is to reduce the "living off the land" attack surface after initial code execution.

The upstream project lives at linux-application-whitelisting/fapolicyd. The 1.3.x branch, current across RHEL 10, Fedora 41+, and Alma/Rocky 9.5, added the ftype subject (magic-byte based file type detection), the split rules.d/ layout, and support for LMDB-backed trust databases that scale to hundreds of thousands of entries without measurable startup delay.

How is fapolicyd different from SELinux and AppArmor?

The question I get most often is why an SELinux-enforcing system needs fapolicyd at all. Short answer: they solve different problems and stack cleanly.

SELinux is a Mandatory Access Control (MAC) system that constrains what a running process can access based on labels. It answers "may httpd_t read /etc/shadow?" AppArmor does the same job with pathnames instead of labels. Neither of them cares whether the binary that spawned the process was legitimate. If an attacker phishes a system administrator, drops a rogue /usr/local/bin/updater, and it gets executed via cron, SELinux happily transitions the process into unconfined_t or a default type and lets it run. fapolicyd refuses the execve because the file isn't in the trust database.

Conversely, fapolicyd doesn't restrict what a legitimately allowlisted process does after it starts. A compromised nginx binary that fapolicyd trusts can still be jailed by SELinux's httpd_t domain or by MAC policies for containers. The two mechanisms are complementary, and deploying both is the CIS-recommended posture for RHEL 9 and 10.

DimensionfapolicydSELinuxAppArmor
Enforcement layerfanotify permission eventsLSM hooks in kernelLSM hooks in kernel
Primary questionMay this binary run?May this labeled process access this object?May this pathname access this object?
Policy sourceRPM signatures + rules.dCompiled policy modulesProfile files
Native distrosRHEL, Fedora, Alma, RockyRHEL, Fedora, Debian (opt-in)Ubuntu, SUSE, Debian
Bypass by renameImpossible (hash-based)Possible if label transitions matchDepends on pathname pattern
Runtime overhead~1–3% on exec-heavy loads<1% typical<1% typical

Installing and enabling fapolicyd on RHEL 9, RHEL 10, and Fedora

fapolicyd ships in the default AppStream on RHEL 9 and 10, and in the base repo on Fedora. Installation is a one-liner, but there are two flags I always set before the first start: switch to permissive = 1, and enable syslog_format so denials land in /var/log/fapolicyd-access.log in a parseable form.

# RHEL 10 / Fedora 41+ / Alma 10 / Rocky 10
sudo dnf install -y fapolicyd fapolicyd-selinux

# Seed the trust database from installed RPMs
sudo fapolicyd-cli --update

# Flip to permissive for the initial soak
sudo sed -i 's/^permissive = 0/permissive = 1/' /etc/fapolicyd/fapolicyd.conf
sudo sed -i 's/^syslog_format = .*/syslog_format = rule,dec,perm,auid,pid,exe,:,path,ftype,trust/' \
    /etc/fapolicyd/fapolicyd.conf

# Enable and start
sudo systemctl enable --now fapolicyd

# Confirm it's reading events
sudo systemctl status fapolicyd
sudo fapolicyd-cli --check-status

The fapolicyd-selinux subpackage installs the confinement policy for the daemon itself. Without it, fapolicyd runs as unconfined_service_t, and a kernel compromise of fanotify (see CVE-2023-3812 for the last such class of bug) could disable the enforcement path. On RHEL 10 the SELinux policy for fapolicyd is already in the base selinux-policy-targeted; on RHEL 9 you must install the standalone package.

The fapolicyd trust database explained

The trust database is fapolicyd's ground truth for "is this binary legitimate?" It's an LMDB store at /var/lib/fapolicyd/, populated from two sources: the RPM database (via the rpmdb backend) and a manually maintained file backend for binaries you install outside dnf. Every entry maps a full pathname to a SHA-256 and a size, and both must match at exec time for the file to be considered trusted.

The default configuration in /etc/fapolicyd/fapolicyd.conf lists trust sources in priority order:

trust = rpmdb,file
integrity = sha256

To inspect trust:

# List everything the daemon considers trusted
sudo fapolicyd-cli --list-trust | head

# Check a specific file
sudo fapolicyd-cli --file check /usr/bin/curl

# Add an out-of-tree binary (custom compiled tool)
sudo fapolicyd-cli --file add /opt/myapp/bin/agent
sudo fapolicyd-cli --update
sudo systemctl reload fapolicyd

The file backend lives at /etc/fapolicyd/fapolicyd.trust for global entries and /etc/fapolicyd/trust.d/ for drop-in fragments. In managed fleets I keep the drop-in directory Ansible-owned and generate one file per application, which makes rollback trivial. Whenever an RPM upgrades, dnf triggers a dnf-fapolicyd plugin that recomputes hashes; if you skip that plugin, upgrades will start being denied at the next execve.

How do you write fapolicyd rules?

Rules live in /etc/fapolicyd/rules.d/, ordered by filename prefix (10-* is evaluated before 90-*). The syntax is a decision followed by subject/object clauses joined by ::

decision perm subj_attr=value : obj_attr=value

The default rule set on RHEL 10 is a good starting point. It allows anything in the trust database and denies everything else. Here is a distilled example that adds three custom rules I use for a hardened application server:

#  /etc/fapolicyd/rules.d/30-custom.rules

# 1. Allow the internal Node.js runtime to execute JS from /srv/app only
allow perm=execute exe=/usr/bin/node : dir=/srv/app/
deny_audit perm=execute exe=/usr/bin/node : all

# 2. Block Python from running any script that isn't in a trusted directory
allow perm=any exe=/usr/bin/python3.12 : dir=/usr/lib/python3.12/
allow perm=any exe=/usr/bin/python3.12 : dir=/opt/company/scripts/
deny_audit perm=any exe=/usr/bin/python3.12 : all

# 3. Deny any executable dropped in world-writable dirs, log with audit tag
deny_audit perm=execute all : dir=/tmp/
deny_audit perm=execute all : dir=/dev/shm/
deny_audit perm=execute all : dir=/var/tmp/

Two subtleties. First, the deny_audit action denies and emits an audit event with the tag fanotify, which is easy to grep from auditd's ausearch. Use plain deny in enforce mode only for high-volume noise you've already characterised. Second, the ftype subject added in 1.3 matches magic bytes: ftype=application/x-executable catches ELF, and ftype=text/x-python catches Python scripts. That's how you block interpreters from executing scripts via a shebang bypass.

Reload after every edit:

sudo fapolicyd-cli --check-config     # syntax check
sudo systemctl reload fapolicyd       # atomic policy swap
sudo fapolicyd-cli --list             # confirm rule ordering

Rolling out safely: permissive to enforce

Every production incident I've seen with fapolicyd traces to one thing: skipping the permissive soak. In permissive mode, the daemon evaluates rules and logs decisions but never returns FAN_DENY. Run at least seven days of realistic workload (including scheduled jobs, package upgrades, and a full backup cycle) before flipping the switch.

Collect denials that would have happened:

# Show would-be denials from the last 24h
sudo journalctl -u fapolicyd --since '24 hours ago' \
    | grep -E 'dec=deny'

# Or parse the access log directly
sudo awk -F'[ =]' '$3=="deny_audit"{print $0}' \
    /var/log/fapolicyd-access.log | sort -u

# Aggregate by executable
sudo grep dec=deny /var/log/fapolicyd-access.log \
    | grep -oP 'exe=\S+' | sort | uniq -c | sort -rn | head

For every unique exe= in that list, decide: legitimate (add to trust or a rule), legitimate but risky (allow with narrower scope), or truly unwanted (leave denied). When the daily denial rate has dropped to near-zero for a full 48 hours, switch to enforce:

sudo sed -i 's/^permissive = 1/permissive = 0/' /etc/fapolicyd/fapolicyd.conf
sudo systemctl reload fapolicyd
# Keep an eye on the next few minutes
sudo journalctl -u fapolicyd -f

Handling Python, Node, and other interpreters

Interpreted languages are where naive allowlisting collapses. If you trust /usr/bin/python3.12, an attacker who lands a foothold can execute arbitrary Python by dropping a script and running python3.12 evil.py. Blocking that requires mediating the interpreter's open() of the script file, not just its execve.

fapolicyd 1.3 handles this with ftype and the perm=open permission. The pattern I use:

# Only Python scripts under company-controlled paths may be opened by python3
allow perm=open exe=/usr/bin/python3.12 ftype=text/x-python : dir=/usr/lib64/python3.12/
allow perm=open exe=/usr/bin/python3.12 ftype=text/x-python : dir=/opt/company/
allow perm=open exe=/usr/bin/python3.12 ftype=text/x-python : dir=/srv/app/
deny_audit perm=open exe=/usr/bin/python3.12 ftype=text/x-python : all

The same idea applies to Node, Ruby, Perl, and shell. For Bash specifically, gate on ftype=text/x-shellscript. Be aware that fapolicyd can't introspect content served by pipes, so curl https://... | bash is still possible if the invoking binary is trusted. That's a good argument for pairing fapolicyd with strict egress firewalling (see the nftables zero-trust guide).

fapolicyd with Podman, containers, and rootless workloads

Container payloads are opaque to fapolicyd running on the host. The daemon sees the container runtime (/usr/bin/runc, /usr/bin/crun) executing, and the workload inside a mount namespace doesn't touch host paths in the trust database. That's usually fine, since the container's own image supplies its binaries and the runtime is trusted, but two configuration steps make it dependable.

First, ensure crun and runc are trusted; RHEL packages both from the RPM database, so they're covered automatically. Second, in rootless Podman, the shift into a user namespace means fapolicyd sees the executing UID mapped, which occasionally trips rules keyed on auid=. The fix is to use subj_attr keyed on gid= instead of UID for rootless flows.

# Allow rootless Podman under the "containers" group to run runc
allow perm=execute gid=containers : path=/usr/bin/runc
allow perm=execute gid=containers : path=/usr/bin/crun

For image content itself, the correct control is not fapolicyd but image signing and admission. That's where Wolfi or distroless base images combined with Cosign verification pull their weight. fapolicyd is the host-side layer; image supply chain is a separate concern.

CIS, STIG, and PCI DSS compliance mapping

fapolicyd is the reference implementation for several compliance controls on RHEL. Here's the mapping I keep in my head:

  • CIS RHEL 10 Benchmark 1.1.0: sections 4.1.3.x require an application allowlisting mechanism, and fapolicyd in enforce mode with the default ruleset satisfies them.
  • DISA STIG RHEL 9 V1R14 (updated May 2026): V-258032 explicitly names fapolicyd as an acceptable control. STIG V1 for RHEL 10 (draft July 2026) inherits the same finding IDs.
  • PCI DSS 4.0.1 Requirement 5.3.1: anti-malware mechanisms must be active on all system components; fapolicyd's execve gating counts as a preventive anti-malware layer when paired with signature-based scanning.
  • NIST SP 800-53 Rev.5: controls CM-7(2), CM-7(5), and SI-3(8). fapolicyd maps to the "least functionality" and "authorised software" families.

The easiest way to validate compliance in a fleet is with an OpenSCAP profile. See the OpenSCAP + Ansible guide for how to bake fapolicyd checks into a nightly scan. The relevant OVAL definition is rule_service_fapolicyd_enabled, and the xccdf_org.ssgproject.content_rule_configure_usbguard_auditbackend profile bundle in ComplianceAsCode 0.1.75+ includes fapolicyd rule checks out of the box.

Can you use fapolicyd on Debian or Ubuntu?

Technically yes, practically no. There's a Debian package in the trixie-backports repo (as of Debian 13 in August 2026), but the trust database backend is limited to the file source. There's no dpkg integration equivalent to the RPM backend, so every apt upgrade requires you to rebuild trust manually. That doesn't scale.

The realistic Debian/Ubuntu alternatives, ordered by strength:

  1. IMA appraisal with signed policies: kernel-level integrity gating using signatures under the _ima keyring. Ubuntu 24.04 has the tooling, and 26.04 will improve it.
  2. Landlock LSM 6.7+: per-process file access restriction. Useful for hardening individual daemons, though not a fleet-wide allowlist.
  3. AppArmor "attack surface reduction" profiles: pathname-based execute restrictions.
  4. Snap/Flatpak confinement: only useful for packaged applications.

If you must run application allowlisting on Ubuntu, my current recommendation is IMA appraisal. See the IMA and EVM guide for the setup.

Performance impact and known limitations

fapolicyd's overhead is dominated by three costs: fanotify queue processing (constant time), trust DB lookup (LMDB, O(log n)), and audit emission (only on denials). On a workload doing 5,000 exec/s (an aggressive CI runner), I measure 2.8% additional CPU and about 40–60μs added latency per exec. For typical web servers doing hundreds of execs per hour, the overhead is unmeasurable.

Known limitations to plan around:

  • Static binaries with mmap(): fapolicyd gates on FAN_OPEN_EXEC_PERM, but memory-mapped code injection (e.g. memfd_create + execveat) bypasses it in kernels older than 6.1. RHEL 9.4+ and RHEL 10 backport the fix.
  • Kernel modules: fapolicyd does not gate init_module. Use kernel.modules_disabled in sysctl, and signed modules, to close that gap.
  • Container images: already noted; use image signing.
  • Race window on new files: there's a tiny window between file write and the next execve. The fapolicyd 1.3 watch_fs option closes most of it by watching filesystem events proactively.

Once you've internalised those, fapolicyd is the highest-leverage control I can add to a RHEL host after SELinux is already enforcing. The rules stay small, the compliance story is clean, and the attacker's post-exploitation toolkit shrinks dramatically.

Frequently Asked Questions

What is fapolicyd used for?

fapolicyd enforces application allowlisting on Linux by blocking the execution of any binary or script that is not explicitly trusted, primarily through hashes derived from the RPM database on RHEL and Fedora. It's used to satisfy CIS, STIG, and PCI DSS requirements for "authorised software only" controls, and to blunt post-exploitation on production servers.

Does fapolicyd replace SELinux?

No. SELinux constrains what an already-running process can access; fapolicyd controls whether the process is allowed to start at all. They protect against different threats, and Red Hat's guidance is to run both in enforce mode on any system with a compliance obligation.

How do I stop fapolicyd from blocking a legitimate binary?

Add the binary to the file-based trust source with fapolicyd-cli --file add /path/to/binary, then run fapolicyd-cli --update and systemctl reload fapolicyd. If the binary must run from a non-standard path, prefer a targeted rule in /etc/fapolicyd/rules.d/ over adding trust for the entire directory.

Can fapolicyd run on Ubuntu?

fapolicyd is packaged for Debian 13 backports but lacks dpkg trust integration, so it isn't practical for production Ubuntu fleets. The recommended Ubuntu alternative in 2026 is IMA appraisal with signed policies, optionally combined with Landlock for per-daemon confinement.

How much overhead does fapolicyd add?

On typical workloads the overhead is under 1% CPU and unmeasurable latency. On execve-heavy workloads (build farms, CI runners) expect roughly 2–3% CPU and 40–60 microseconds added per execve, driven mostly by LMDB trust lookup and fanotify queue processing.

Yuki Tanaka
About the Author Yuki Tanaka

Linux kernel security engineer with a background in eBPF and LSM. Likes hardening more than she likes sleeping.