Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hardening NixOS

✔️ Table of Contents

guy fawks hacker

Securing your NixOS system begins with a philosophy of minimalism, explicit configuration, and proactive control. As desktop Linux attracts more novice users, it has become an increasingly valuable target for attackers. This makes it crucial to adopt security best practices early to protect your desktop from common attack vectors and to avoid configuration mistakes that could expose vulnerabilities.

⚠️ Warning: I am not a security expert. This guide presents various options for hardening NixOS, but it is your responsibility to evaluate whether each adjustment suits your specific needs and environment. Security hardening and process isolation can introduce stability challenges, compatibility issues, or unexpected behavior. Additionally, these protections often come with performance tradeoffs. Always conduct thorough research, there are no plug and play one size fits all security solutions.

That said, I typically write about what I’m implementing myself to deepen understanding and share what works for me. --Source means the proceeding paragraph came from --Source, you can often click to check for yourself. If you use some common sense with a bit of caution you could end up with a more secure NixOS system that fits your needs.

Much of this guide draws inspiration or recommendations from the well-known Linux Hardening Guide by Madaidan’s Insecurities. Madaidan’s work is widely regarded in technical and security circles as one of the most comprehensive and rigorously researched sources on practical Linux security, frequently cited for its depth and actionable advice. For example, much of the original basis for hardening for nix-mineral came from this guide as well. This can be a starting point but shouldn’t be blindly followed either, always do your own research, things change frequently. Madaidan is also a contributor to both Kicksecure and Whonix.

For an article with apposing perspectives, see debunking-madaidans-insecurities. We can learn from both and hopefully find something in between that is closer to the truth.

Note on SELinux and AppArmor: While NixOS can provide a high degree of security through its immutable and declarative nature, it’s important to understand the limitations regarding Mandatory Access Control (MAC) frameworks. Neither SELinux nor AppArmor are fully supported or widely used in the NixOS ecosystem. You can do a lot to secure NixOS but if anonymity and isolation are paramount, I recommend booting into a Tails USB stick. Or using Whonix.

☝️ The unique file structure of NixOS, particularly the immutable /nix/store, makes it difficult to implement and manage the file-labeling mechanisms that these frameworks rely on. There are ongoing community efforts to improve support, but as of now, they are considered experimental and not a standard part of a typical NixOS configuration. For an immutable distro that implements SELinux by default at a system level as well as many other hardening techniques, see Fedora secureblue.

Containers and VMs are beyond the scope of this chapter but can also enhance security and sandboxing if configured correctly. See Running NixOS in a VM for more details on running NixOS in a Secureblue VM for additional security.

It’s crucial to document every change you make. By creating smaller, feature-complete commits, each with a descriptive message, you’re building a clear history. This approach makes it far simpler to revert a breaking change and quickly identify what went wrong. Over time, this discipline allows you to create security-focused checklists and ensure all angles are covered, building a more robust and secure system.

Don’t rely on single solutions or products, develop processes and defense in depth. Think ahead and fail securely so that a single failure doesn’t mean total insecurity.

Attackers often monitor the latest Linux CVEs (Common Vulnerabilities and Exposures) and check if and when specific distributions like NixOS have implemented fixes. The unstable branch will receive the security patches and fixes faster than stable which is another thing to keep in mind.

Check out the Hardening NixOS Baseline Hardening README for baseline hardening recommendations and best practices.

There is something to be said about the window manager you use. GNOME, KDE Plasma, and Sway secure privileged Wayland protocols like screencopy. This means that on environments outside of GNOME, KDE, and Sway, applications can access screen content of the entire desktop. This implicitly includes the content of other applications. It’s primarily for this reason that Silverblue, Kinoite, and Sericea images are recommended. COSMIC has plans to fix this. –secureblue Images

For example, to disable Xwayland for sway on home-manager you would add:

wayland.windowManager.sway = {
  enable = true;
  extraConfig = ''
    xwayland disable
  '';
}
  • You may get an error saying you’re only able to disable xwayland at boot, restart your system and you’ll be all set.

You can explicitly disable xdg-desktop-portal-wlr with systemd in your configuration.nix like this:

# configuration.nix
systemd.user.services."xdg-desktop-portal-wlr" = {
  enable = false;  # Masks/stops the wlr service
};
xdg.portal.wlr.enable = false;

Common Attack Vectors for Linux

✔️ Common Attack Vectors in Linux

Privilege escalation: The unauthorized act of gaining elevated permissions rather than legitimate, controlled privilege use. It’s a very common tactic that threat actors use to take over a system, steal data, delete files, and more.

Processes to protect against Privilege escalation


Use after Free/Double free:

Use-After-Free (UAF) is a type of software vulnerability that occurs in memory unsafe languages (C C++) when a program continues to use a memory location after it has been freed or deallocated.

Double free: is a flaw where a program frees the same memory block twice using free() or delete, leading to undefined behavior and potential exploitation.

Mitigation techniques include the use of hardened allocators such as hardened_malloc, which improve memory management to detect and prevent UAF and double-free bugs. Recent versions of glibc also incorporate built-in checks to catch double frees.


Unauthorized Access:

Unauthorized access is the entry or use of your system, networks, or data by individuals without permission. It’s a common way for adversaries to exfiltrate data, execute malicious code, and cause damage.

Protections against Unauthorized Access

  • Strong Passwords, MFA, and robust Secrets management. In 2025, 22% of breaches involved stolen credentials overall; in basic web app attacks, 88% used stolen credentials. –StrongDM data-breach-statistics

  • Close unused ports with a Firewall

  • Encrypt data in transit and at rest

  • Watch your Logs, and deploy intrusion detection systems such as AIDE.

  • SQL Injection CWE, SQL injection is the most common critical web application vulnerability.

  • Cross Site Scripting (XSS)


Misconfiguration

  • With many new users trying NixOS, misconfiguration is common and an easy way for an attacker to gain control over your system.

  • It is recommended to start slowly and try to ensure that you understand your configuration. Avoid copy-pasting config files that you don’t understand yet.


Zero Day Exploits:

The term “Zero-Day” refers to a security vulnerability or flaw that is unknown to the software developers or security teams, meaning they have had zero days to create a patch or fix for it. This term is often associated with concepts such as Vulnerabilities, Exploits, and Threats, and it’s important to distinguish among them:

  • A Zero-Day Vulnerability is a previously undiscovered security weakness or flaw in software that malicious actors can exploit.

  • A Zero-Day Exploit describes the specific method or technique attackers use to take advantage of that vulnerability to compromise a system.

  • A Zero-Day Attack happens when malicious actors launch an attack using a zero-day exploit before the software vendor has had a chance to patch or fix the vulnerability.

  • Project Zero’s 0day spreadsheet. You’ll see that a majority of zero-days are Memory Corruption bugs.

  • Zero-Day tracking project

  • Trend Micro’s zero day inituative


Minimal Installation with LUKS

Begin with NixOS’s minimal installation image. This gives you a base system with only essential tools and no extras that could introduce vulnerabilities.

NixOS’s declarative model makes auditing the installed packages and services easy, do so regularly.


Manual Encrypted Install Following the Manual

Encryption is the process of using an algorithm to scramble plaintext data into ciphertext, making it unreadable except to a person who has the key to decrypt it.

Data at rest is data in storage, such as a computer’s or a servers hard disk.

Data at rest encryption (typically hard disk encryption), secures the documents, directories, and files behind an encryption key. Encrypting your data at rest prevents data leakage, physical theft, unauthorized access, and more as long as the key management scheme isn’t compromised.


Guided Encrypted BTRFS Subvol install using disko

Use LUKS encryption to protect your data at rest, the following guide is a minimal disko encrypted installation: Encrypted Install


Installing Software

The 2025 Edgescan study examined full-stack applications and found that one-third contained critical or severe vulnerabilities, putting them at risk. Over 45% of large enterprises leave unresolved vulnerabilities for more than a year. This shows the necessity of containing your apps in sandboxes when possible. –edgescan Vulnerability Report

⚠️ For system security it is strongly advised to not install proprietary, non-freedom software. Instead, use of Free Software is recommended –Kicksecure

# configuration.nix
nixpkgs.config.allowUnfree = false;

To explicitly disable it for flakes:

# ...snip...
pkgs = import nixpkgs {
  system = "x86_64-linux";
  config = {
    allowUnfree = false;
  };
};
# ...snip...

Most users don’t fully understand that running any software without sandboxing gives it unrestricted access to their user data and system resources. There is a widespread lack of awareness that Linux apps generally run with the full permissions of the user. It’s easy to overlook the fact that “trusted source” doesn’t mean “safe to run uncontained”. –summarized from kicksecure docs

Pre-Install Recommendations

When installing software, first check search.nixos, and follow the Homepage link to ensure that said package is maintained.

For example, when I search for doas, and go to the Homepage link, I can see that the most recent commit was made 3 years ago. For certain software this might not be an issue but doas isn’t one of them.

Looking at the sudo-rs Homepage I can see that it was updated yesterday (11-19-25) and might be a better alternative. It’s maintained and written in a memory safe language.

For critical apps like sudo, you should also check for vulnerabilities in said software. If you did so for sudo-rs, you’d see CVE-2025-64170 and see that it’s been patched. You can then look at the sudo-rs package.nix to ensure that the versions match. (As of 11-20-25 they match).


nixpkgs-unstable Security Overview

  • nixpkgs-unstable tracks the master branch of the Nixpkgs repo and is constantly updated.

  • This branch gets security updates faster, patching vulnerabilities faster.

  • Since it’s a rolling-release, packages are less thoroughly tested. This increases the risk of new, undiscovered bugs or regressions. Some of which could have security implications.

  • The packages are generally the most recent upstream versions, which is important for security-sensitive software like browsers and kernels, as old versions may have publicly known, unpatched vulnerabilities.

  • As the name states, nixpkgs-unstable is less stable and an update is more likely to cause your system to fail to build due to breaking changes in Nix expressions.

  • I personally use unstable for everything, but I don’t mind having to fix issues that arise.


Stable (e.g., nixos-24.05) Security Overview

Stable Nixpkgs channels correspond to point release (e.g., released every 6 months) and are supported for a limited period (typically one month past the next release).

  • Stable channels generally only receive conservative bug and security fixes. Major version bumps for features are typically avoided to maintain “stability against deliberate changes”, which means you won’t get the latest upstream features or general bug fixes.

  • While critical security updates are backported quickly, updates for less critical packages may be slower or not happen at all if they require a significant refactoring or version bump.

  • Stable channels are generally more stable, meaning updates are less likely to introduce breaking changes to your configuration or system environment.

  • Many packages will be older versions. If a critical security vulnerability requires a major upstream version update (which is often avoided in a stable channel), the maintainers must backport the patch, a process which can introduce its own set of risks and delays.


What should you use?

The primary security trade-off is between patching speed for known vulnerabilities and stability/exposure to new bugs:

  • Choose unstable if you prioritize getting the latest security fixes (especially for end-user apps like browsers) as soon as they are available upstream, accepting a higher risk of non-security-related system breakage or new, undiscovered bugs.

  • Choose stable if you prioritize system predictability and stability, relying on dedicated backports for critical vulnerabilities, while accepting that non-critical security and bug fixes will be delayed or absent until the next major release.

A common hybrid approach is to use the stable channel as the base for the OS and selectively pin specific packages from unstable to ensure they receive rapid security updates.

With flakes it’s easy to add both stable and unstable as flake inputs and access each with some simple logic.

✔️ Click to Expand Flake example using both stable & unstable
{
  description = "NixOS configuration with two or more channels";

 inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs =
    { nixpkgs, nixpkgs-unstable, ... }:
    {
      nixosConfigurations."your-host" = nixpkgs.lib.nixosSystem {
        modules = [
          {
            nixpkgs.overlays = [
              (final: prev: {
                unstable = nixpkgs-unstable.legacyPackages.${prev.system};
                # use this variant if unfree packages are needed:
                # unstable = import nixpkgs-unstable {
                #   inherit prev;
                #   system = prev.system;
                #   config.allowUnfree = true;
                # };
              })
            ];
          }
          ./configuration.nix
        ];
      };
    };
}
  • This is also how you enable unfree packages for flakes rather than in your configuration.nix.

Now you can specify which packages are to be installed with which channel like so:

# configuration.nix
{ pkgs, ... }:
{
  environment.systemPackages = [
    pkgs.firefox
    pkgs.unstable.helix
  ];
  # ...
}

Users and SUID Binaries

Replacing sudo with run0

NOTE: The point here is to avoid using the setuid binary (sudo), run0 is a wrapper over systemd-run which speaks over Inter-process Communication Mechanisms (IPC) to PID1 which is considered safer than running a setuid binary. We separate our daily user from administration tasks and authenticate through our admin account. This reduces the attack surface by removing sudo as well as reduces the risk of local privilege escalation.

  • IPC is the mechanism that allows processes to communicate. There are two methods of IPC, shared memory and message passing. An OS can implement both.

  • PID 1 is the first userspace process the kernel starts (the init system), which becomes the ancestor and reaper of all other processes; because it runs as root, is always present, and controls the system lifecycle, any bugs or design issues in PID 1 have outsized security impact and can translate into system-wide compromise or denial of service.

Click to Expand SUID and run0 resources

run0 is not a SUID, it asks the service manager to invoke a command or shell under the target user’s UID. The target command is invoked in an isolated exec context, freshly forked off PID1 without inheriting any context from the client.

The core danger of setuid (Set User ID) lies in its ability to allow a low-privilege user to execute a program with the permissions of the file’s owner, which is most often the powerful root user.

💥 The Danger of setuid

For granting limited, controlled privilege escalation to apps, the primary choices are broadly between traditional setuid/setgid permissions and more modern Linux capabilities. Jump to Capabilities

Use the following command to find all SUID binaries:

sudo find / -perm -4000 -type f -ls 2>/dev/null

The setuid permission is dangerous because it creates a privilege escalation pathway that can be exploited for malicious purposes.

  • Temporary Root Access: When a file has the setuid bit set and is owned by root, any user who executes that program instantly and temporarily gains the full power of the root user while the program runs.

  • If a setuid program (such as passwd, or sudo) contain a security flaw, such as a buffer overflow (Common in C) or improper input validation, an attacker can exploit the flaw.

  • Since the program is running with root privileges, the attacker can execute shell code or commands with root access, completely compromising the entire system.

Normally the root user (UID 0) gets unrestricted access to almost everything on the entire system.

I rebuild/update way too often to completely separate the accounts and allow no admin tasks for my daily user. That may be a better option for servers, etc.

Create an admin user for administrative tasks and remove your daily user from the wheel group, and disable the sudo, su, and pkexec SUIDs:

{ config, pkgs, lib }:
{
users.users.admin = {
    isNormalUser = true;
    description  = "System administrator";
    extraGroups  = [ "wheel" ];   # wheel = sudo
    # run `mkpasswd --method=yescrypt` and replace "changeme" w/ the result
    initialHashedPassword = "changeme";           # change with `passwd admin` later
    openssh.authorizedKeys.keys = [
      # (optional) paste your SSH public key here
      # "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
    ];
  };
    users.groups.admin = {};
    users.mutableUsers = false;

  # --------------------------------------------------------------------
  # 2. Existing daily user – remove from wheel, keep everything else
  # --------------------------------------------------------------------
  users.users.daily = {
    isNormalUser = true;
    description  = "Daily driver account";
    extraGroups  = lib.mkForce [ "networkmanager" "audio" "video" ]; # keep useful groups
    initialHashedPassword = "changeme";
    # Remove `wheel` by *not* listing it (mkForce overrides any default)
  };
  users.groups.daily = {};

security = {
polkit.enable = true;
# Disable sudo
sudo.enable = false;
wrappers = {
    su.setuid = lib.mkForce = false;
    sudo.setuid = lib.mkForce = false;
    sudoedit.setuid = lib.mkForce = false;
    sg.setuid = lib.mkForce = false;
    fusermount.setuid = lib.mkForce = false;
    fusermount3.setuid = lib.mkForce = false;
    mount.setuid = lib.mkForce = false;
    pkexec.setuid = lib.mkForce = false;
    newgrp.setuid = lib.mkForce = false;
    newgidmap.setuid = lib.mkForce = false;
    newuidmap.setuid= lib.mkForce = false;
};
# Or hyprlock, required for swaylock to accept your password
pam.services.swaylock = {
  text = ''
    auth include login
    account include login
    password include login
    session include login
  '';
  };
};

The security.wrappers... removes the setuid bit making the commands unusable removing the SUID vulnerabilities for su and pkexec. You can find the other SUID wrappers in /run/wrappers/bin/, such as fusermount and more.

SUID’s that can be disabled:

  • umount: Allows unprivileged users to unmount devices listed in your fstab.

  • mount: Same as above but for mounting.

  • sg: Executes a command as a different group.

  • mtr-packet: Used by mtr to create network sockets.

  • fusermount, fusermount3: Allows unprivileged users to mount FUSE filesystems. Can be disabled if you don’t use FUSE (e.g., Appimages, etc.)

  • newuidmap, newgidmap: Used for user namespace creation (Often used for unprivileged containers). (Disable if you don’t use unprivileged containers/namespaces)


Never Disable:

  • unix_chkpwd: This is a core PAM helper to securely check user passwords against the root-readable /etc/shadow.

Check again which SUID binaries are active:

sudo find / -perm -4000 -type f -ls 2>/dev/null

You will have to use run0 to authenticate your daily user, for example:

run0 nixos-rebuild switch --flake .

Since run0 doesn’t cache results and nixos-rebuild calls on Polkit 3 times, so on every rebuild, you will be asked for your password 3 times which isn’t ideal. I found the following workaround that will only ask for your password once.

Add the following to your configuration.nix, replacing user-name with your username:

 security.polkit.extraConfig = ''
     polkit.addRule(function(action, subject) {
       if (subject.user == "user-name") {
         if (action.id.indexOf("org.nixos") == 0) {
           polkit.log("Caching admin authentication for single NixOS operation");
           return polkit.Result.AUTH_ADMIN_KEEP;
         }
       }
     });
   '';

Create a zsh function for easy access:

# zsh.nix
#...snip...
initContent = ''
  fr() {
    run0 nixos-rebuild switch --flake "/home/$USER/flake#"$(hostname)
  }
'';

Needless to say, this is less secure but much more convenient than entering your password 3 times on every single rebuild.

Without the pam settings for swaylock, it won’t accept your password to log back in.

run0 Usage Example

When you are in a privileged shell, run0 changes the color of the background to red to remind you of this.

Example creating a user:

  1. run0

  2. adduser admin

  3. usermod -aG wheel admin

  4. passwd admin

  5. exit

  6. reboot

This is just an example, since we manage our users declaratively the user created would be discarded on the next rebuild because of the users.mutableUsers = false; setting. You could of course change this to true to manage your users imperatively but I don’t recommend it.


Capabilities

✔️ Click to expand capabilities examples

One way to help get rid of setuid binaries is to replace them with capabilities. I personally only remove the SUID bit and don’t try to replace with capabilities as of now. You can still use the commands from security.wrappers such as run0 su -.

Capabilities provide a subset of what is available to root to a process. This breaks up root privileges into smaller units that can independently grant access to processes. This reduces the full set of privileges, decreasing the risk of exploitation.

(This is just an example):

{
  # a setuid root program
  doas =
    { setuid = true;
      owner = "root";
      group = "root";
      source = "${pkgs.doas}/bin/doas";
    };

  # a setgid program
  locate =
    { setgid = true;
      owner = "root";
      group = "mlocate";
      source = "${pkgs.locate}/bin/locate";
    };

  # a program with the CAP_NET_RAW capability
  ping =
    { owner = "root";
      group = "root";
      capabilities = "cap_net_raw+ep";
      source = "${pkgs.iputils.out}/bin/ping";
    };
}

List the highest capability number for your kernel with:

cat /proc/sys/kernel/cap_last_cap
# Output:
40

List available Linux capabilities:

capsh --print

List processes:

ps
# Example Output
PID    TTY     TIME   CMD
8063   pts/1    02     zsh
cat /proc/8063/status | grep Cap
# Output
CapInh: 0000000800000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
capsh --decode=000001ffffffffff
# Output
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore

cap_net_raw: Allows the program to use raw and unbuffered network sockets, which is what ping and mtr-packet need to send ICMP packets.

cap_sys_admin: Grants a variety of system administration operations, including the ability to perform FUSE mounts. This is a powerful capability, but it’s still more restrictive than full root SUID.

  • +ep: This is crucial. It stands for:
    • e (Effective): The set of capabilities actually used by the process when running.

    • p (Permitted): The set of capabilities that can be enabled by the process.

By using this approach, you are following the security principle of least privilege, significantly reducing the attack surface compared to traditional SUID binaries.


Impermanence

Impermanence, especially when using a tmpfs as the root filesystem, provides several significant security benefits. The core principle is that impermanence defeats persistence, a fundamental goal for any attacker.

When you use a root-as-tmpfs setup on NixOS, the boot process loads the entire operating system from the read-only Nix store into a tmpfs in RAM. The mutable directories, such as /etc and /var, are then created on this RAM disk. When the system is shut down, the tmpfs is wiped, leaving the on-disk storage untouched and secure.

This means you get a fresh, secure boot every time, making it much harder for an attacker to maintain a presence on your system.

Replace timesyncd with a chron job that enables Network Time Security (NTS)

This is implementing the GrapheneOS/secureblue NTS chrony settings to NixOS:

{ config
, ...
}:
{
  services.chrony = {
    enable = true;
    enableNTS = true;
    servers = [
        "server time.cloudflare.com iburst nts"
        "server ntppool1.time.nl iburst nts"
        "server nts.netnod.se iburst nts"
        "server ptbtime1.ptb.de iburst nts"
        "server time.dfm.dk iburst nts"
        "server time.cifelli.xyz iburst nts"
     ];
    # havent worked out the kinks yet
  #  extraConfig = ''
  #      minsources 3
  #      authselectmode require

  #      # EF
  #      dscp 46

  #      driftfile /var/lib/chrony/drift
  #      dumpdir /var/lib/chrony
  #      ntsdumpdir /var/lib/chrony

  #      leapseclist /usr/share/zoneinfo/leap-seconds.list
  #      makestep 1.0 3

  #      rtconutc

  #      cmdport 0

  #      noclientlog
  #  '';
  };
}

Ensure NTS is being used with:

sudo chronyc -N authdata

Secure Boot

Enable a UEFI password or Administrator password where it requires authentication in order to access the UEFI/BIOS.

Secure Boot helps ensure only signed, trusted kernels and bootloaders are executed at startup.

Useful Resources:

✔️ Click to Expand Secure Boot Resources

Practical Lanzaboote Secure Boot setup for NixOS: Guide:Secure Boot on NixOS with Lanzaboote


The Kernel

The Kernel Self Protection Project:

Given the kernel’s central role, it’s a frequent target for malicious actors, making robust hardening essential.

NixOS provides a hardened profile that applies a set of security-focused kernel and system configurations.

For flakes, you could do something like the following in your configuration.nix or equivalent to import hardened.nix and enable profiles.hardened:

# configuration.nix
{ pkgs, inputs, ... }: let
   modulesPath = "${inputs.nixpkgs}/nixos/modules";

in {
  imports = [ "${modulesPath}/profiles/hardened.nix" ];

}
  • There is a proposal to remove it completely that has gained ground, the following thread discusses why: Discourse Thread

  • PR #383438 Proposed removal PR.

  • Check hardened.nix to see exactly what adding it enables to avoid duplicates and conflicts moving on. I included this for completeness, the choice is yours if you want to use it or not.

Choosing your Kernel

See which kernel you’re currently using with:

# show the kernel release
uname -r
# show kernel version, hostname, and architecture
uname -a

Show the configuration of your current kernel:

zcat /proc/config.gz
# ...snip...
#
# Compression
#
CONFIG_CRYPTO_DEFLATE=m
CONFIG_CRYPTO_LZO=y
CONFIG_CRYPTO_842=m
CONFIG_CRYPTO_LZ4=m
CONFIG_CRYPTO_LZ4HC=m
CONFIG_CRYPTO_ZSTD=y
# end of Compression
# ...snip...

The NixOS Manual states that the default Linux kernel configuration should be fine for most users.

The Linux kernel is typically released under two forms: stable and long-term support (LTS). Choosing either has consequences, do your research. Stable vs. LTS kernels

OR, you can choose the hardened kernel for a kernel that prioritizes security over everything else.


The Hardened Kernel

NOTE: Expect breakage when using the hardened kernel. linux-hardened completely disables unprivileged user namespaces, which are required for Flatpak, chromium-based browsers, and more.

The linuxPackages_latest_hardened attribute has been deprecated. If you want to use a hardened kernel, it is now recommended to use linux_hardened, which is aliased to linux_default.kernel.

You can find the latest available hardened kernel packages by searching pkgs/top-level/linux-kernels.nix.

It is recommended to use linux_hardened without specifying a version, such as:

boot.kernelPackages = pkgs.linuxPackages_hardened;

linux_hardened is aliased to the linux_default.kernel.

Note that this not only replaces the kernel, but also packages that are specific to the kernel version, such as NVIDIA video drivers. This also removes your ability to use the .extend kernel attribute, they are only available to kernel package sets (e.g., linuxPackages_hardened)

  • If you decide to use this, read further before rebuilding.

You can inspect nixpkgs/pkgs/os-specific/linux/kernel/hardened/patches.json to see the metadata of the patches that are applied. You can then follow the links in the .json file to see the patch diffs.


sysctl

A tool for checking the security hardening options of the Linux kernel:

environment.systemPackages = [ pkgs.kernel-hardening-checker ];

sysctl is a tool that allows you to view or modify kernel settings and enable/disable different features.

Check all sysctl parameters against the kernel-hardening-checker recommendations:

sudo sysctl -a > params.txt
kernel-hardening-checker -l /proc/cmdline -c /proc/config.gz -s ./params.txt

Check the value of a specific parameter:

sudo sysctl -a | grep "kernel.kptr_restrict"
# Output:
kernel.kptr_restrict = 2

Check Active Linux Security Modules:

cat /sys/kernel/security/lsm
# Output:
File: /sys/kernel/security/lsm
capability,landlock,yama,bpf,apparmor

Check Kernel Configuration Options:

zcat /proc/config.gz | grep CONFIG_SECURITY_SELINUX
zcat /proc/config.gz | grep CONFIG_HARDENED_USERCOPY
zcat /proc/config.gz | grep CONFIG_STACKPROTECTOR

Since it is difficult to see exactly what enabling the hardened_kernel does. Before rebuilding, you could do something like this to see exactly what is added:

sudo sysctl -a > before.txt

And after the rebuild:

sudo sysctl -a > after.txt

And finally run a diff on them:

diff before.txt after.txt

You can also diff against after.txt for future changes to avoid duplicates, this seems easier to me than trying to parse through the patches.


Kernel Security Settings

security = {
      protectKernelImage = true;
      lockKernelModules = false; # this breaks iptables, wireguard, and virtd

      # force-enable the Page Table Isolation (PTI) Linux kernel feature
      forcePageTableIsolation = true;

      # User namespaces are required for sandboxing.
      # this means you cannot set `"user.max_user_namespaces" = 0;` in sysctl
      allowUserNamespaces = true;

      # Disable unprivileged user namespaces, unless containers are enabled
      unprivilegedUsernsClone = config.virtualisation.containers.enable;
      allowSimultaneousMultithreading = true;
}

Further Hardening with sysctl

sysctl hardening settings further reinforce kernel-level protections. The hardened kernel includes security patches and stricter defaults, but it doesn’t cover all runtime tunables. Refer to the above commands to get a diff of the changes.

boot.kernel.sysctl: Runtime parameters of the Linux kernel, as set by sysctl(8). Note that the sysctl parameters names must be enclosed in quotes. Values may be a string, integer, boolean, or null.

Check what each setting does sysctl-explorer

Refer to madadaidans-insecurities#sysctl-kernel for the following settings and their explainations.

Also see the Kernel Self Protection Projects sysctls

  boot.kernel.sysctl = {
    "fs.suid_dumpable" = 0;
    # prevent pointer leaks
    "kernel.kptr_restrict" = 2;
    # restrict kernel log to CAP_SYSLOG capability
    "kernel.dmesg_restrict" = 1;
    # Note: certian container runtimes or browser sandboxes might rely on the following
    # restrict eBPF to the CAP_BPF capability
    "kernel.unprivileged_bpf_disabled" = 1;
    # should be enabled along with bpf above
    # "net.core.bpf_jit_harden" = 2;
    # restrict loading TTY line disciplines to the CAP_SYS_MODULE
    "dev.tty.ldisk_autoload" = 0;
    # prevent exploit of use-after-free flaws
    "vm.unprivileged_userfaultfd" = 0;
    # kexec is used to boot another kernel during runtime and can be abused
    "kernel.kexec_load_disabled" = 1;
    # Kernel self-protection
    # SysRq exposes a lot of potentially dangerous debugging functionality to unprivileged users
    # 4 makes it so a user can only use the secure attention key. A value of 0 would disable completely
    "kernel.sysrq" = 4;
    # disable unprivileged user namespaces, Note: Docker, NH, and other apps may need this
    # "kernel.unprivileged_userns_clone" = 0; # Set to 1 because it makes NH and other programs fail
    # This should be set to 0 if you don't rely on flatpak, NH, Docker, etc.
    "kernel.unprivileged_userns_clone" = 1;
    # restrict all usage of performance events to the CAP_PERFMON capability
    "kernel.perf_event_paranoid" = 3;

    # Network
    # protect against SYN flood attacks (denial of service attack)
    "net.ipv4.tcp_syncookies" = 1;
    # protection against TIME-WAIT assassination
    "net.ipv4.tcp_rfc1337" = 1;
    # enable source validation of packets received (prevents IP spoofing)
    "net.ipv4.conf.default.rp_filter" = 1;
    "net.ipv4.conf.all.rp_filter" = 1;

    "net.ipv4.conf.all.accept_redirects" = 0;
    "net.ipv4.conf.default.accept_redirects" = 0;
    "net.ipv4.conf.all.secure_redirects" = 0;
    "net.ipv4.conf.default.secure_redirects" = 0;
    # Protect against IP spoofing
    "net.ipv6.conf.all.accept_redirects" = 0;
    "net.ipv6.conf.default.accept_redirects" = 0;
    "net.ipv4.conf.all.send_redirects" = 0;
    "net.ipv4.conf.default.send_redirects" = 0;

    # prevent man-in-the-middle attacks
    "net.ipv4.icmp_echo_ignore_all" = 1;

    # ignore ICMP request, helps avoid Smurf attacks
    "net.ipv4.conf.all.forwarding" = 0;
    "net.ipv4.conf.default.accept_source_route" = 0;
    "net.ipv4.conf.all.accept_source_route" = 0;
    "net.ipv6.conf.all.accept_source_route" = 0;
    "net.ipv6.conf.default.accept_source_route" = 0;
    # Reverse path filtering causes the kernel to do source validation of
    "net.ipv6.conf.all.forwarding" = 0;
    "net.ipv6.conf.all.accept_ra" = 0;
    "net.ipv6.conf.default.accept_ra" = 0;

    ## TCP hardening
    # Prevent bogus ICMP errors from filling up logs.
    "net.ipv4.icmp_ignore_bogus_error_responses" = 1;

    # Userspace
    # restrict usage of ptrace
    "kernel.yama.ptrace_scope" = 2;

    # ASLR memory protection (64-bit systems)
    "vm.mmap_rnd_bits" = 32;
    "vm.mmap_rnd_compat_bits" = 16;

    # only permit symlinks to be followed when outside of a world-writable sticky directory
    "fs.protected_symlinks" = 1;
    "fs.protected_hardlinks" = 1;
    # Prevent creating files in potentially attacker-controlled environments
    "fs.protected_fifos" = 2;
    "fs.protected_regular" = 2;

    # Randomize memory
    "kernel.randomize_va_space" = 2;
    # Exec Shield (Stack protection)
    "kernel.exec-shield" = 1;

    ## TCP optimization
    # TCP Fast Open is a TCP extension that reduces network latency by packing
    # data in the sender’s initial TCP SYN. Setting 3 = enable TCP Fast Open for
    # both incoming and outgoing connections:
    "net.ipv4.tcp_fastopen" = 3;
    # Bufferbloat mitigations + slight improvement in throughput & latency
    "net.ipv4.tcp_congestion_control" = "bbr";
    "net.core.default_qdisc" = "cake";
  };

❗ Note: The above settings are fairly aggressive and can break common programs, read the comment warnings.


Hardening Boot Parameters

boot.kernelParams can be used to set additional kernel command line arguments at boot time. It can only be used for built-in modules.

You can find the following settings in the Boot parameters section

# boot.nix
      boot.kernelParams = [
        # make it harder to influence slab cache layout
        "slab_nomerge"
        # enables zeroing of memory during allocation and free time
        # helps mitigate use-after-free vulnerabilaties
        "init_on_alloc=1"
        "init_on_free=1"
        # randomizes page allocator freelist, improving security by
        # making page allocations less predictable
        "page_alloc.shuffel=1"
        # enables Kernel Page Table Isolation, which mitigates Meltdown and
        # prevents some KASLR bypasses
        "pti=on"
        # randomizes the kernel stack offset on each syscall
        # making attacks that rely on a deterministic stack layout difficult
        "randomize_kstack_offset=on"
        # disables vsyscalls, they've been replaced with vDSO
        "vsyscall=none"
        # disables debugfs, which exposes sensitive info about the kernel
        "debugfs=off"
        # certain exploits cause an "oops", this makes the kernel panic if an "oops" occurs
        "oops=panic"
        # only alows kernel modules that have been signed with a valid key to be loaded
        # making it harder to load malicious kernel modules
        # can make VirtualBox or Nvidia drivers unusable
        "module.sig_enforce=1"
        # prevents user space code excalation
        "lockdown=confidentiality"
        # "rd.udev.log_level=3"
        # "udev.log_priority=3"
      ];

This is a thoughtful start to hardening boot parameters, there are more recommendations in the guide.

Kernel modules for hardware devices are generally loaded automatically by udev. You can force a module to be loaded via boot.kernelModules.


Hardening Modprobe

You can use both extraModprobeConfig & blacklistedKernelModules to disable different features. If you prefer, you can place these in the next section as well.

boot.extraModprobeConfig = ''
     # firewire and thunderbolt
    install firewire-core /bin/false
    install firewire_core /bin/false
    install firewire-ohci /bin/false
    install firewire_ohci /bin/false
    install firewire_sbp2 /bin/false
    install firewire-sbp2 /bin/false
    install firewire-net /bin/false
    install thunderbolt /bin/false
    install ohci1394 /bin/false
    install sbp2 /bin/false
    install dv1394 /bin/false
    install raw1394 /bin/false
    install video1394 /bin/false
'';
# OR
#boot.blacklistedKernelModules = [
#  "firewire-core"
#  # ... snip ...
#];

Blacklisting Kernel Parameters

Blacklisting unused kernel modules reduces the attack surface.

boot.blacklistedKernelModules: List of names of kernel modules that should not be loaded automatically by the hardware probing code.

You can find the following settings in the Blacklisting Kernel Modules Section

      boot.blacklistedKernelModules = [
        # Obscure networking protocols
        "dccp"   # Datagram Congestion Control Protocol
        "sctp"  # Stream Control Transmission Protocol
        "rds"  # Reliable Datagram Sockets
        "tipc"  # Transparent Inter-Process Communication
        "n-hdlc" # High-level Data Link Control
        "ax25"  # Amateur X.25
        "netrom"  # NetRom
        "x25"     # X.25
        "rose"
        "decnet"
        "econet"
        "af_802154"  # IEEE 802.15.4
        "ipx"  # Internetwork Packet Exchange
        "appletalk"
        "psnap"  # SubnetworkAccess Protocol
        "p8023"  # Novell raw IEE 802.3
        "p8022"  # IEE 802.3
        "can"   # Controller Area Network
        "atm"
        # Various rare filesystems
        "cramfs"
        "freevxfs"
        "jffs2"
        "hfs"
        "hfsplus"
        "udf"

        # "squashfs"  # compressed read-only file system used for Live CDs
        # "cifs"  # cmb (Common Internet File System)
        # "nfs"  # Network File System
        # "nfsv3"
        # "nfsv4"
        # "ksmbd"  # SMB3 Kernel Server
        # "gfs2"  # Global File System 2
        # vivid driver is only useful for testing purposes and has been the
        # cause of privilege escalation vulnerabilities
        # "vivid"
      ];

As with the kernelParameters above, there are more suggestions in the guide, I have used the above parameters along with the commented out ones and had no issues.

Also see SecureBlue’s blacklist.conf for more ideas.


Hardened Memory Allocator

NOTE: There is a performance cost to enabling a hardened memory allocator, and some apps will not work without a workaround such as Firefox, Thunderbird, Torbrowser, LibreWolf, and ZenBrowser to name a few.

With memory corruption bugs being the leading zero day category, it’s clearly something that you should be concerned with.

The grapheneOS hardened_malloc is available for NixOS in two variants, add either to your configuration.nix or equivalent to apply them:

  1. environment.memoryAllocator.provider = "graphene-hardened";: This is the default configuration template that has all normal optional security features enabled. It’s aggressive, you can expect app breakage and a performance cost.

  2. environment.memoryAllocator.provider = "graphene-hardened-light";: The light template disables the slap quarantines, write after free check, slot randomization and raises the guard slab interval from 1 to 8 but leaves zero-on-free and slab canaries enabled. This version has solid performance and is still far more secure than the standard allocator.

libhardened_malloc.so is typically installed to /usr/local/lib/libhardened_malloc.so and referenced from /etc/ld.so.preload.


Hardening Systemd

systemd is the core “init system” and service manager that controls how services, daemons, and basic system processes are started, stopped and supervised on modern Linux distributions, including NixOS. It provides a suite of basic building blocks for a Linux system as well as a system and service manager that runs as PID 1 and starts the rest of the system.

Because it launches and supervises almost all system services, hardening systemd means raising the baseline security of your entire system.

Disable coredumps:

systemd.coredump.enable = false;
# ➡️ Sets the kernel's resource limit (ulimit -c 0)
  security.pam.loginLimits = [
    {
      domain = "*"; # Applies to all users/sessions
      type = "-"; # Set both soft and hard limits
      item = "core"; # The soft/hard limit item
      value = "0";   # Core dumps size is limited to 0 (effectively disabled)
    }
  ];

Disabling coredumps helps save space and improves security/privacy because when a program fails, a coredump contains an exact copy of a programs running memory at the time of the crash. This can inadvertently expose sensitive data.

If a program is handling private information when it crashes, the core dump file could contain:

  • Passwords: Stored in memory before being sent or hashed.

  • Encryption Keys: Used for securing network connections.

  • Personal Info: Chat messages, website forms, etc.

It can give a minor performance upgrade and does reduce the attack surface. If a malicious program were to gain access to your system, one of the first things it might look for are core dump files to extract sensitive data. By disabling them, you eliminate this potential source of information leakage.

dbus-broker is generally considered more secure and robust but isn’t the default as of yet. To set dbus-broker as the default:

  users.groups.netdev = {};
  services = {
    dbus.implementation = "broker";
    logrotate.enable = true;
    journald = {
      storage = "volatile"; # Store logs in memory
      upload.enable = false; # Disable remote log upload (the default)
      extraConfig = ''
        SystemMaxUse=500M
        SystemMaxFileSize=50M
      '';
    };
  };
  • dbus-broker is more resilient to resource exhaustion attacks and integrates better with Linux security features.

  • Rethinking-the-dbus-message-bus

  • Setting storage = "volatile" tells journald to keep log data only in memory. There is a tradeoff though, If you need long-term auditing or troubleshooting after a reboot, this will not preserve system logs.

  • upload.enable is for forwarding log messages to remote servers, setting this to false prevents accidental leaks of potentially sensitive or internal system information.

  • Enabling logrotate prevents your disk from filling with excessive legacy/service log files. These are the classic plain-text logs.

  • Systemd uses journald which stores logs in a binary format

You can check the security status with:

systemd-analyze security
# or for a detailed view of individual services security posture
systemd-analyze security NetworkManager

Optionally disable vulnerable services to reduce the attack surface, obviously don’t disable what you need, or change your habits:

services = {
    # mDNS/DNS-SD
    avahi.enable = false;
    # Geoclue (location services)
    geoclue2.enable = false;
    udisks2.enable = false;
    accounts-daemon.enable = false;
  };
  # Only needed for WWAN/3G/4G modems, otherwise it runs `mmcli` unnecessarily
  networking.modemmanager.enable = false;
  # Bluetooth has a long history of vulnerabilities
  hardware.bluetooth.enable = false;
  # Prefer manual upgrades on a hardened system
  system.autoUpgrade.enable = false;

Further reading on systemd:

✔️ Click to Expand Systemd Resources

The following is a repo containing many of the Systemd hardening settings in NixOS format:

nix-system-services-hardened

For example, to harden bluetooth you could add the following to your configuration.nix or equivalent:

systemd.services = {
      bluetooth.serviceConfig = {
      ProtectKernelTunables = lib.mkDefault true;
      ProtectKernelModules = lib.mkDefault true;
      ProtectKernelLogs = lib.mkDefault true;
      ProtectHostname = true;
      ProtectControlGroups = true;
      ProtectProc = "invisible";
      SystemCallFilter = [
        "~@obsolete"
        "~@cpu-emulation"
        "~@swap"
        "~@reboot"
        "~@mount"
      ];
      SystemCallArchitectures = "native";
    };
}

As you can see from above, you typically use the serviceConfig attribute to harden settings for systemd services.

systemd-analyze security bluetooth
→ Overall exposure level for bluetooth.service: 3.3 OK 🙂
Click to expand `systemd.nix` example implementing many of the recommendations
{lib, ...}: {
  systemd.services = {
    # "home-manager-jr".after = ["network-online.target"];
    # "home-manager-jr".wantedBy = ["multi-user.target"];
    "user@".serviceConfig = {
      ProtectSystem = "strict";
      ProtectClock = true;
      ProtectHostname = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      ProtectProc = "invisible";
      PrivateTmp = true;
      PrivateNetwork = true;
      MemoryDenyWriteExecute = false;
      RestrictAddressFamilies = [
        "AF_UNIX"
        "AF_NETLINK"
        "AF_BLUETOOTH"
      ];
      RestrictNamespaces = true;
      RestrictRealtime = true;
      RestrictSUIDSGID = true;
      SystemCallFilter = [
        "~@keyring"
        "~@swap"
        "~@debug"
        "~@module"
        "~@obsolete"
        "~@cpu-emulation"
      ];
      SystemCallArchitectures = "native";
    };
    acpid.serviceConfig = {
      ProtectSystem = "full";
      ProtectHome = true;
      RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
      SystemCallFilter = "~@clock @cpu-emulation @debug @module @mount @raw-io @reboot @swap";
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
    };

    auditd.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "full";
      ProtectHome = true;
      ProtectHostname = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectControlGroups = true;
      ProtectProc = "invisible";
      ProtectClock = true;
      PrivateTmp = true;
      PrivateNetwork = true;
      PrivateMounts = true;
      PrivateDevices = true;
      RestrictNamespaces = true;
      RestrictRealtime = true;
      RestrictSUIDSGID = true;
      RestrictAddressFamilies = [
        "~AF_INET6"
        "~AF_INET"
        "~AF_PACKET"
      ];
      MemoryDenyWriteExecute = true;
      LockPersonality = true;
      SystemCallFilter = [
        "~@clock"
        "~@module"
        "~@mount"
        "~@swap"
        "~@obsolete"
        "~@cpu-emulation"
      ];
      SystemCallArchitectures = "native";
      CapabilityBoundingSet = [
        "~CAP_CHOWN"
        "~CAP_FSETID"
        "~CAP_SETFCAP"
      ];
    };

    cups.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "full";
      ProtectHome = true;
      ProtectKernelModules = true;
      ProtectKernelTunables = true;
      ProtectKernelLogs = true;
      ProtectControlGroups = true;
      ProtectHostname = true;
      ProtectClock = true;
      ProtectProc = "invisible";
      RestrictRealtime = true;
      RestrictNamespaces = true;
      RestrictSUIDSGID = true;
      RestrictAddressFamilies = [
        "AF_UNIX"
        "AF_NETLINK"
        "AF_INET"
        "AF_INET6"
        "AF_PACKET"
      ];

      MemoryDenyWriteExecute = true;
      SystemCallFilter = [
        "~@clock"
        "~@reboot"
        "~@debug"
        "~@module"
        "~@swap"
        "~@obsolete"
        "~@cpu-emulation"
      ];
      SystemCallArchitectures = "native";
      LockPersonality = true;
    };

    NetworkManager.serviceConfig = {
      NoNewPrivileges = true;
      ProtectHome = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      ProtectControlGroups = true;
      ProtectClock = true;
      ProtectHostname = true;
      ProtectProc = "invisible";
      PrivateTmp = true;
      RestrictRealtime = true;
      RestrictAddressFamilies = [
        "AF_UNIX"
        "AF_NETLINK"
        "AF_INET"
        "AF_INET6"
        "AF_PACKET"
      ];
      RestrictNamespaces = true;
      RestrictSUIDSGID = true;
      MemoryDenyWriteExecute = true;
      SystemCallFilter = [
        "~@mount"
        "~@module"
        "~@swap"
        "~@obsolete"
        "~@cpu-emulation"
        "ptrace"
      ];
      SystemCallArchitectures = "native";
      LockPersonality = true;
    };

    wpa_supplicant.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "strict";
      ProtectHome = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      ProtectControlGroups = true;
      ProtectClock = true;
      ProtectHostname = true;
      ProtectProc = "invisible";
      PrivateTmp = true;
      PrivateMounts = true;
      RestrictRealtime = true;
      RestrictAddressFamilies = [
        "AF_UNIX"
        "AF_NETLINK"
        "AF_INET"
        "AF_INET6"
        "AF_PACKET"
      ];
      RestrictNamespaces = true;
      RestrictSUIDSGID = true;
      MemoryDenyWriteExecute = true;
      SystemCallFilter = [
        "~@mount"
        "~@raw-io"
        "~@privileged"
        "~@keyring"
        "~@reboot"
        "~@module"
        "~@swap"
        "~@resources"
        "~@obsolete"
        "~@cpu-emulation"
        "ptrace"
      ];
      SystemCallArchitectures = "native";
      LockPersonality = true;
      CapabilityBoundingSet = "CAP_NET_ADMIN CAP_NET_RAW";
    };

    dbus.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "stric";
      ProtectControlGroups = true;
      ProtectHome = true;
      ProtectHostname = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      PrivateMounts = true;
      PrivateDevices = true;
      PrivateTmp = true;
      RestrictSUIDSGID = true;
      RestrictRealtime = true;
      RestrictAddressFamilies = [
        "AF_UNIX"
      ];
      RestrictNamespaces = true;
      SystemCallErrorNumber = "EPERM";
      SystemCallArchitectures = "native";
      SystemCallFilter = [
        "~@obsolete"
        "~@resources"
        "~@debug"
        "~@mount"
        "~@reboot"
        "~@swap"
        "~@cpu-emulation"
      ];
      LockPersonality = true;
      IPAddressDeny = ["0.0.0.0/0" "::/0"];
      MemoryDenyWriteExecute = true;
      DevicePolicy = "closed";
      UMask = 0077;
    };

    nscd.serviceConfig = {
      ProtectClock = true;
      ProtectHostname = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      ProtectControlGroups = true;
      ProtectProc = "invisible";
      RestrictNamespaces = true;
      RestrictRealtime = true;
      MemoryDenyWriteExecute = true;
      LockPersonality = true;
      SystemCallFilter = [
        "~@mount"
        "~@swap"
        "~@clock"
        "~@obsolete"
        "~@cpu-emulation"
      ];
      SystemCallArchitectures = "native";
      CapabilityBoundingSet = [
        "~CAP_CHOWN"
        "~CAP_FSETID"
        "~CAP_SETFCAP"
      ];
    };
    bluetooth.serviceConfig = {
      ProtectKernelTunables = lib.mkDefault true;
      ProtectKernelModules = lib.mkDefault true;
      ProtectKernelLogs = lib.mkDefault true;
      ProtectHostname = true;
      ProtectControlGroups = true;
      ProtectProc = "invisible";
      SystemCallFilter = [
        "~@obsolete"
        "~@cpu-emulation"
        "~@swap"
        "~@reboot"
        "~@mount"
      ];
      SystemCallArchitectures = "native";
    };
    systemd-rfkill.serviceConfig = {
      ProtectSystem = "strict";
      ProtectHome = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectControlGroups = true;
      ProtectClock = true;
      ProtectProc = "invisible";
      ProcSubset = "pid";
      PrivateTmp = true;
      MemoryDenyWriteExecute = true;
      NoNewPrivileges = true;
      LockPersonality = true;
      RestrictRealtime = true;
      SystemCallArchitectures = "native";
      UMask = "0077";
      IPAddressDeny = "any";
    };
    systemd-machined.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "strict";
      ProtectHome = true;
      ProtectClock = true;
      ProtectHostname = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectKernelLogs = true;
      ProtectProc = "invisible";
      PrivateTmp = true;
      PrivateMounts = true;
      PrivateUsers = true;
      PrivateNetwork = true;
      RestrictNamespaces = true;
      RestrictRealtime = true;
      RestrictSUIDSGID = true;
      RestrictAddressFamilies = ["AF_UNIX"];
      MemoryDenyWriteExecute = true;
      SystemCallArchitectures = "native";
    };
    systemd-udevd.serviceConfig = {
      NoNewPrivileges = true;
      ProtectSystem = "strict";
      ProtectHome = true;
      ProtectKernelLogs = true;
      ProtectControlGroups = true;
      ProtectClock = true;
      ProtectProc = "invisible";
      RestrictNamespaces = true;
      CapabilityBoundingSet = "~CAP_SYS_PTRACE ~CAP_SYS_PACCT";
    };
    nix-daemon.serviceConfig = {
      NoNewPrivileges = true;
      ProtectControlGroups = true;
      ProtectKernelModules = true;
      PrivateMounts = true;
      PrivateTmp = true;
      PrivateDevices = true;
      RestrictSUIDSGID = true;
      RestrictRealtime = true;
      RestrictNamespaces = ["~cgroup"];
      RestrictAddressFamilies = [
        "AF_UNIX"
        "AF_NETLINK"
        "AF_INET6"
        "AF_INET"
      ];
      CapabilityBoundingSet = [
        "~CAP_SYS_CHROOT"
        "~CAP_BPF"
        "~CAP_AUDIT_WRITE"
        "~CAP_AUDIT_CONTROL"
        "~CAP_AUDIT_READ"
        "~CAP_SYS_PTRACE"
        "~CAP_SYS_NICE"
        "~CAP_SYS_RESOURCE"
        "~CAP_SYS_RAWIO"
        "~CAP_SYS_TIME"
        "~CAP_SYS_PACCT"
        "~CAP_LINUX_IMMUTABLE"
        "~CAP_IPC_LOCK"
        "~CAP_WAKE_ALARM"
        "~CAP_SYS_TTY_CONFIG"
        "~CAP_SYS_BOOT"
        "~CAP_LEASE"
        "~CAP_BLOCK_SUSPEND"
        "~CAP_MAC_ADMIN"
        "~CAP_MAC_OVERRIDE"
      ];
      SystemCallErrorNumber = "EPERM";
      SystemCallArchitectures = "native";
      SystemCallFilter = [
        "~@resources"
        "~@module"
        "~@obsolete"
        "~@debug"
        "~@reboot"
        "~@swap"
        "~@cpu-emulation"
        "~@clock"
        "~@raw-io"
      ];
      LockPersonality = true;
      MemoryDenyWriteExecute = false;
      DevicePolicy = "closed";
      UMask = 0077;
    };
    systemd-journald.serviceConfig = {
      NoNewPrivileges = true;
      ProtectProc = "invisible";
      ProtectHostname = true;
      PrivateMounts = true;
    };
  };
}

Lynis and other tools

Lynis is a security auditing tool for systems based on UNIX like Linux, macOS, BSD, and others.–lynis repo

chkrootkit was removed as it is unmaintained and archived upstream.

Installation:

environment.systemPackages = [
pkgs.lynis
pkgs.clamav
pkgs.aide
 ];
✔️ Click to Expand AIDE Example

AIDE is an intrusion detection system (IDS) that will notify us whenever it detects that a potential intrusion has occurred. When a system is compromised, attackers typically will try to change file permissions and escalate to the root user account and start to modify system files, AIDE can detect this.

To set up AIDE on your system follow these steps:

  1. Create the aide.conf:
sudo mkdir -p /var/lib/aide && cd /var/lib/aide/
sudo hx aide.conf

Add the following content to /var/lib/aide/aide.conf:

# aide.conf
# Example configuration file for AIDE.

@@define DBDIR /var/lib/aide

# The location of the database to be read.
database_in=file:@@{DBDIR}/aide.db.gz

# The location of the database to be written.
#database_out=sql:host:port:database:login_name:passwd:table
#database_out=file:aide.db.new
database_out=file:@@{DBDIR}/aide.db.new.gz

# Whether to gzip the output to database
gzip_dbout=yes

log_level=info

report_url=file:/var/log/aide/aide.log
report_url=stdout
#report_url=stderr
#NOT IMPLEMENTED report_url=mailto:root@foo.com
#NOT IMPLEMENTED report_url=syslog:LOG_AUTH

# These are the default rules.
#
#p:      permissions
#i:      inode:
#n:      number of links
#u:      user
#g:      group
#s:      size
#b:      block count
#m:      mtime
#a:      atime
#c:      ctime
#S:      check for growing size
#md5:    md5 checksum
#sha1:   sha1 checksum
#rmd160: rmd160 checksum
#tiger:  tiger checksum
#haval:  haval checksum
#gost:   gost checksum
#crc32:  crc32 checksum
#R:      p+i+n+u+g+s+m+c+md5
#L:      p+i+n+u+g
#E:      Empty group
#>:      Growing logfile p+u+g+i+n+S

# You can create custom rules like this.

NORMAL = R+b+sha512

DIR = p+i+n+u+g

# Next decide what directories/files you want in the database.

/boot   NORMAL
/bin    NORMAL
/sbin   NORMAL
/lib    NORMAL
/opt    NORMAL
/usr    NORMAL
/root   NORMAL

# Check only permissions, inode, user and group for /etc, but
# cover some important files closely.
/etc    p+i+u+g
!/etc/mtab
/etc/exports  NORMAL
/etc/fstab    NORMAL
/etc/passwd   NORMAL
/etc/group    NORMAL
/etc/gshadow  NORMAL
/etc/shadow   NORMAL

/var/log   p+n+u+g

# With AIDE's default verbosity level of 5, these would give lots of
# warnings upon tree traversal. It might change with future version.
#
#=/lost\+found    DIR
#=/home           DIR

Create the logfile:

sudo mkdir -p /var/log/aide
sudo touch /var/log/aide/aide.log
  1. Generate the initial database, this will store the checksums of all files that it’s configured to monitor. Take note of the location of the new database, mine was /etc/aide.db.new
sudo aide --config /var/lib/aide/aide.conf --init
  1. Move the new database and remove the .new:
sudo mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
ls /var/lib/aide/
aide.conf   aide.db.gz
  1. Check with AIDE:
sudo aide --check --config /var/lib/aide/aide.conf
Start timestamp: 2025-09-05 09:50:07 -0400 (AIDE 0.19.2)
AIDE found NO differences between database and filesystem. Looks okay!!
  1. Whenever you make changes to system files, or especially after running a system update or installing new tools, you have to rescan all files to update their checksums in the AIDE database:
sudo aide --update --config /var/lib/aide/aide.conf

Unfortunately, AIDE doesn’t automatically replace the old database so you have to rename the new one again:

sudo mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz

And finally check again:

sudo aide --check --config /var/lib/aide/aide.conf
✔️ Click to Expand clamav.nix Example
{pkgs, ...}: {
  environment.systemPackages = with pkgs; [
    clamav
  ];
  services.clamav = {
    # Enable clamd daemon
    daemon.enable = true;
    updater.enable = true;
    updater.frequency = 12; # Number of database checks per day
    scanner = {
      enable = true;
      # 4:00 AM
      interval = "*-*-* 04:00:00";
      scanDirectories = [
        "/home"
        "/var/lib"
        "/tmp"
        "/etc"
        "/var/tmp"
      ];
    };
  };
}

Lynis Usage:

sudo lynis show commands
# Output:
Commands:
lynis audit
lynis configure
lynis generate
lynis show
lynis update
lynis upload-only

sudo lynis audit system
# Output:
  Lynis security scan details:

  Hardening index : 79 [###############     ]
  Tests performed : 234
  Plugins enabled : 0

  Components:
  - Firewall               [V]
  - Malware scanner        [V]

  Scan mode:
  Normal [V]  Forensics [ ]  Integration [ ]  Pentest [ ]

  Lynis modules:
  - Compliance status      [?]
  - Security audit         [V]
  - Vulnerability scan     [V]
  • The “Lynis hardening index” is an overall impression on how well a system is hardened. However, this is just an indicator on measures taken - not a percentage of how safe a system might be. A score over 75 typically indicates a system with more than average safety measures implemented.

  • Lynis will give you more recommendations for securing your system as well.

If you use clamscan, create the following log file:

sudo touch /var/log/clamscan.log

Example cron job for clamav & aide:

{pkgs, ...}: {
  services.cron = {
    enable = true;
    # messages.enable = true;
    systemCronJobs = [
      # Every day at 2:00 AM, run clamscan as root and append output to a log file
      "0 2 * * * root ${pkgs.clamav}/bin/clamscan -r /home >> /var/log/clamscan.log"
      "0 11 * * * ${pkgs.aide}/bin/aide --check --config /var/lib/aide/aide.conf"
    ];
  };
}

ClamAV usage:

You can run clamav manually with:

# Recursive Scan:
sudo clamscan -r ~/home

❗ NOTE: You only need either the individual pkgs.clamav with the cron job OR the clamd-daemon module. clamdscan is for software integration and uses a different user that doesn’t have permission to scan your files. You can use clamdscan --fdpass /path/to/scan to pass the necessary file permissions. NOTE: clamdscan runs in the background, you can watch it with top.

Securing SSH

Security information: Changing SSH configuration settings can significantly impact the security of your system(s). It is crucial to have a solid understanding of what you are doing before making any adjustments. Avoid blindly copying and pasting examples, including those from this Wiki page, without conducting a thorough analysis. Failure to do so may compromise the security of your system(s) and lead to potential vulnerabilities. Take the time to comprehend the implications of your actions and ensure that any changes made are done thoughtfully and with care. –NixOS Wiki

❗ NOTE: Choose one, either ssh-agent or gpg-agent

  1. Use normal SSH keys generated with ssh-keygen, this is recommended unless you have a good reason for not using it.

OR

  1. Use a GPG key with gpg-agent (which acts as your SSH agent). Complex, and harder to understand in my opinion.

My setup caused conflicts when enabling programs.ssh.startAgent so I chose gpg-agent personally.

There are situations where you are required to use one or the other like for headless CI/CD environments, ssh-keygen is required.

Further reading:

✔️ Click to Expand Resourses on OpenSSH

Key generation

ssh-keygen

The ed25519 algorithm is significantly faster and more secure when compared to RSA. You can also specify the key derivation function (KDF) rounds to strengthen protection even more.

For example, to generate a strong key for GitHub:

ssh-keygen -t ed25519 -a 32 -f ~/.ssh/id_ed25519_github_$(date +%Y-%m-%d) -C "SSH Key for GitHub"
  • -t is for type

  • -a 32 sets the number of KDF rounds. The standard is usually good enough, adding extra rounds can make it harder to brute-force.

  • -f is for filename

OpenSSH Server

First of all, if you don’t use SSH don’t enable it in the first place. If you do use SSH, it’s important to understand what that opens you up to.

The following are some recommendations from Mozilla on OpenSSH:

The following OpenSSH setup is based on the above guidelines with strong algorithms, and best practices: (EDITED: 10-07-25 to follow best-practices on post-quantum crypto)

{config, ...}: {
  config = {
    services = {
      fail2ban = {
        enable = true;
        maxretry = 5;
        bantime = "1h";
        # ignoreIP = [
        # "172.16.0.0/12"
        # "192.168.0.0/16"
        # "2601:881:8100:8de0:31e6:ac52:b5be:462a"
        # "matrix.org"
        # "app.element.io" # don't ratelimit matrix users
        # ];

        bantime-increment = {
          enable = true; # Enable increment of bantime after each violation
          multipliers = "1 2 4 8 16 32 64 128 256";
          maxtime = "168h"; # Do not ban for more than 1 week
          overalljails = true; # Calculate the bantime based on all the violations
        };
      };
      openssh = {
        enable = true;
        settings = {
          PasswordAuthentication = false;
          PermitEmptyPasswords = false;
          PermitTunnel = false;
          UseDns = false;
          KbdInteractiveAuthentication = false;
          X11Forwarding = config.services.xserver.enable;
          MaxAuthTries = 3;
          MaxSessions = 2;
          ClientAliveInterval = 300;
          ClientAliveCountMax = 0;
          AllowUsers = ["your-user"];
          TCPKeepAlive = false;
          AllowTcpForwarding = false;
          AllowAgentForwarding = false;
          LogLevel = "VERBOSE";
          PermitRootLogin = "no";
          KexAlgorithms = [
            # Post-Quantum: https://www.openssh.org/pq.html
            "mlkem768x25519-sha256"
            "sntrup761x25519-sha512"
            "curve25519-sha256@libssh.org"
            "ecdh-sha2-nistp521"
            "ecdh-sha2-nistp384"
            "ecdh-sha2-nistp256"
            "diffie-hellman-group-exchange-sha256"
          ];
          Ciphers = [
            "aes256-gcm@openssh.com"
            "aes128-gcm@openssh.com"
            # stream cipher alternative to aes256, proven to be resilient
            # Very fast on basically anything
            "chacha20-poly1305@openssh.com"
            # industry standard, fast if you have AES-NI hardware
            "aes256-ctr"
            "aes192-ctr"
            "aes128-ctr"
          ];
          Macs = [
            # Combines the SHA-512 hash func with a secret key to create a MAC
            "hmac-sha2-512-etm@openssh.com"
            "hmac-sha2-256-etm@openssh.com"
            "umac-128-etm@openssh.com"
            "hmac-sha2-512"
            "hmac-sha2-256"
            "umac-128@openssh.com"
          ];
        };
        # These keys will be generated for you
        hostKeys = [
          {
            path = "/etc/ssh/ssh_host_ed25519_key";
            type = "ed25519";
          }
        ];
      };
    };
  };
}

TCP port 22 (ssh) is opened automatically if the SSH daemon is enabled (services.openssh.enable = true;)

Much of the SSH hardening settings came from ryanseipp’s secure-ssh Guide with some additions of my own.

Fail2Ban is an intrusion prevention software framework. It’s designed to prevent brute-force attacks by scanning log files for suspicious activity, such as repeated failed login attempts.

OpenSSH is the primary tool for secure remote access for NixOS. Enabling it activates the OpenSSH server on the system, allowing incoming SSH connections.

The above configuration is a robust setup for securing an SSH server by:

  • Preventing brute-force attacks with Fail2Ban

  • Eliminating password authentication in favor of more secure SSH keys

  • Restricting user access and preventing root login

  • Disabling potentially risky forwarding features (tunnel, TCP, agent)

  • Enforce the use of strong, modern cryptographic algorithms for all SSH communications.

  • Enhanced logging for better auditing.

Further Reading:


Encrypted Secrets

Never store secrets in plain text in repositories. Use something like sops-nix, which lets you keep encrypted secrets under version control declaratively.

Another option is agenix

Sops-nix Guide

Protect your secrets, the following guide is on setting up Sops on NixOS: Sops Encrypted Secrets


Auditd

To enable the Linux Audit Daemon (auditd) and define a very basic rule set, you can use the following NixOS configuration. This example demonstrates how to log every program execution (execve) on a 64-bit architecture.

# modules/security/auditd-minimal.nix (or directly in configuration.nix)
{
  # start as early in the boot process as possible
  boot.kernelParams = ["audit=1"];
  security.auditd.enable = true;
  security.audit.enable = true;
  security.audit.rules = [
    # Log all program executions on 64-bit architecture
    "-a exit,always -F arch=b64 -S execve"
  ];
}
  • audit=1 Enables auditing at the kernel level very early in the boot process. Without this, some events could be missed.

  • security.auditd.enable = true; Ensures the auditd userspace daemon is started.

  • While often enabled together, security.audit.enable specifically refers to enabling the NixOS module for audit rules generation.

  • execve (program executions)

  • This is just a basic configuration, there is much more that can be tracked.


USB Port Protection

It’s important to protect your USB ports to prevent BadUSB attacks, data exfiltration, unauthorized device access, malware injection, etc.

To get a list of your connected USB devices you can use lsusb from the usbutils package.

lsusb

To list the devices recognized by USBGuard, run:

sudo usbguard list-devices

Change your-user to your username:

# usbguard.nix
{
  config,
  pkgs,
  lib,
  ...
}: let
  inherit (lib) mkIf;
  cfg = config.custom.security.usbguard;
in {
  options.custom.security.usbguard = {
    enable = lib.mkEnableOption "usbguard";
  };

  config = mkIf cfg.enable {
    services.usbguard = {
      enable = true;
      IPCAllowedUsers = ["root" "your-user"];
    # presentDevicePolicy refers to how to treat USB devices that are already connected when the daemon starts
      presentDevicePolicy = "allow";
      rules = ''
        # allow `only` devices with mass storage interfaces (USB Mass Storage)
        allow with-interface equals { 08:*:* }
        # allow mice and keyboards
        # allow with-interface equals { 03:*:* }

        # Reject devices with suspicious combination of interfaces
        reject with-interface all-of { 08:*:* 03:00:* }
        reject with-interface all-of { 08:*:* 03:01:* }
        reject with-interface all-of { 08:*:* e0:*:* }
        reject with-interface all-of { 08:*:* 02:*:* }
      '';
    };

    environment.systemPackages = [pkgs.usbguard];
  };
}

The above settings can be found in RedHat UsbGuard

The only allow rule is for devices with only mass storage interfaces (08:*:*) i.e., USB Mass storage devices, devices like keyboards and mice (which use interface class 03:*:*) implicitly not allowed.

The reject rules reject devices with a suspicious combination of interfaces. A USB drive that implements a keyboard or a network interface is very suspicious, these reject rules prevent that.

The presentDevicePolicy = "allow"; allows any device that is present at daemon start up even if they’re not explicitly allowed. However, newly plugged in devices must match an allow rule or get denied implicitly.

The presentDevicePolicy should be one of: # one of "apply-policy"(default, evaluate the rule set for every present device), "block", "reject", "keep" (keep whatever state the device is currently in), or "allow", which is used in the example.

There is also the usbguard-notifier

And enable it with the following in your configuration.nix or equivalent:

# configuration.nix
imports = [
    ./usbguard.nix
];
custom.security.usbguard.enable = true;

❗ If you are ever unsure about a setting that you want to harden and think that it could possibly break your system you can always use a specialisation reversing the action and choose it’s generation at boot up. For example, to force-reverse the above settings you could:

# configuration.nix
specialisation.no-usbguard.configuration = {
    services.usbguard.enable = lib.mkForce false;
};
  • This is a situation where I recommend this, it’s easy to lock yourself out of your keyboard, mouse, etc. when trying to configure this.

Further Reading:


Doas over sudo (Warning Doas is unmaintained)

✔️ Click to Expand Unmaintained Doas example

NOTE: I have moved to run0 for authentication which is included by default with systemd. It’s actually a symlink to the existing systemd-run tool. It behaves like a secure sudo alternative: it spawns a transient service under PID 1 for privilege escalation, without relying on SUID (set user ID) binaries.

⚠️ Warning the Nixpkgs version of doas, OpenDoas is unmaintained and hasn’t been updated in 3 to 4 years. If you don’t like run0, consider using sudo-rs. I’m leaving this here for now, may remove it in the future to not promote using unmaintained software, you’ve been warned.

  • Why run0

  • SUID = “Set User ID”: When a binary has the SUID bit set, it runs with the privileges of the file’s owner (often root). There is a long history of vulnerabilities with SUID binaries.

For a more minimalist version of sudo with a smaller codebase and attack surface, consider doas. Replace userName with your username:

# doas.nix
{
  lib,
  config,
  pkgs, # Add pkgs if you need to access user information
  ...
}: let
  cfg = config.custom.security.doas;
in {
  options.custom.security.doas = {
    enable = lib.mkEnableOption "doas";
  };

  config = lib.mkIf cfg.enable {
    # Disable sudo
    security.sudo.enable = false;

    # Enable and configure `doas`.
    security.doas = {
      enable = true;
      extraRules = [
        {
          # Grant doas access specifically to your user
          users = ["userName"]; # <--- Only give access to your user
          # persist = true; # Convenient but less secure
          # noPass = true;    # Convenient but even less secure
          keepEnv = true; # Often necessary
          # Optional: You can also specify which commands they can run, e.g.:
          # cmd = "ALL"; # Allows running all commands (default if not specified)
          # cmd = "/run/current-system/sw/bin/nixos-rebuild"; # Only allow specific command
        }
      ];
    };

    # Add an alias to the shell for backward-compat and convenience.
    environment.shellAliases = {
      sudo = "doas";
    };
  };
}

You would then import this into your configuration.nix and enable/disable it with the following:

# configuration.nix

imports = [
    ./doas.nix
];

custom.security.doas.enable = true;

❗ NOTE: Many people opt for the less secure groups = ["wheel"]; in the above configuration instead of users = ["userName"]; to give wider access, the choice is yours.


Firejail

❗️ Critics such as madaidan say that Firejail worsens security by acting as a privilege escalation hole. Firejail requires the executable to be setuid, meaning it runs with root privileges.This is risky because any vulnerability in Firejail can lead to privilege escalation. This combined with many convenience features and complicated command line flags leads to a large attack surface.

❗ WARNING: Running untrusted code is never safe, sandboxing cannot change this. –Arch Wiki

# firejail.nix
{
  pkgs,
  lib,
  ...
}: {
  programs.firejail = {
    enable = true;
    wrappedBinaries = {
      # Sandbox a web browser
      librewolf = {
        executable = "${lib.getBin pkgs.librewolf}/bin/librewolf";
        profile = "${pkgs.firejail}/etc/firejail/librewolf.profile";
      };
      # Sandbox a file manager
      thunar = {
        executable = "${lib.getBin pkgs.xfce.thunar}/bin/thunar";
        profile = "${pkgs.firejail}/etc/firejail/thunar.profile";
      };
      # Sandbox a document viewer
      zathura = {
        executable = "${lib.getBin pkgs.zathura}/bin/zathura";
        profile = "${pkgs.firejail}/etc/firejail/zathura.profile";
      };
    };
  };
}

wrappedBinaries is a list of applications you want to run inside a sandbox. Only the apps in the wrappedBinaries attribute set will be automatically firejailed when launched the usual way.

Other apps may be started manually using firejail <app>, or added to wrappedBinaries if you want automatic sandboxing, just make sure the profile exists.

To inspect which profiles are available, after rebuilding go to /nix/store/, I used Yazi to search for /firejail and followed it to firejail/etc, where the profiles are.

There are many flags and options available with firejail, I suggest checking out man firejail.

There are comments explaining what’s going on in: firejail/package.nix

Firejail is a SUID program that reduces the risk of security breaches by restricting the running environment of untrusted applications using Linux namespaces and seccomp-bpfFirejail Security Sandbox

It provides sandboxing and access restriction per application, much like what AppArmor/SELinux does at a kernel level. However, it’s not as secure or comprehensive as kernel-enforced MAC systems (AppArmor/SELinux), since it’s a userspace tool and can potentially be bypassed by privilege escalation exploits.


Flatpak

❗️NOTE: You cannot effectively use Firejail with Flatpak apps because of how their sandboxing technologies operate. Flatpak also won’t work with the hardened kernel because they require unprivileged user namespaces which the hardened kernel completely disables.

Apps that don’t have a flatpak equivalent can be further hardened with bubblewrap independently but bubblewrap is not needed on Flatpak apps.

Because of this limited native MAC (Mandatory Access Control) support on NixOS, using Flatpak is often a good approach to get sandboxing and isolation for many GUI apps.

  • Flatpak bundles runtimes and sandbox mechanisms that provide app isolation independently of the host system’s AppArmor or SELinux infrastructure. This can improve security and containment for GUI applications running on NixOS despite the system lacking full native MAC coverage.

  • Flatpak apps benefit from sandboxing through bubblewrap, which isolate apps and restrict access to user/home and system resources.

Add Flatpak with the FlatHub repository for all users:

services.flatpak.enable = true;
  systemd.services.flatpak-repo = {
    wantedBy = [ "multi-user.target" ];
    path = [ pkgs.flatpak ];
    script = ''
      flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
      # Only apps that are verified
      # flatpak remote-add --if-not-exists --subset=verified flathub-verified https://flathub.org/repo/flathub.flatpakrepo
    '';
  };
xdg = {
  portal = {
    enable = true;
    extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
    config.common.default = [ "gtk" ];
  };
};
# Disables screencopy
systemd.user.services."xdg-desktop-portal-wlr" = {
  enable = false;
};

Then you can either find apps through FlatHub or on the cmdline with flatpak search <app>. Flatpak is best used for GUI apps, some CLI apps can be installed through it but not all.

  • There is also nix-flatpak, which enables you to manage your flatpaks declaratively.

  • Flatseal is GUI utility that enables you to review and modify permissions from your Flatpak apps. Many apps by default come with smart-card support, X11 & Wayland support, and more, disabling unnecessary permissions is recommended.

  • Warehouse provides a simple UI to control complex Flatpak options, no cmdline required.

I have heard that it is not recommended to use Flatpak browsers because in order for flatpak to work it has to disable some of the built-in browser sandboxing which can reduce security. I haven’t found any examples of Flatpak browsers being exploited but it’s something to keep in mind.


SeLinux/AppArmor MAC (Mandatory Access Control)

AppArmor is available on NixOS, but is still in a somewhat experimental and evolving state. There are only a few profiles that have been adapted to NixOS, see here Discourse on default-profiles Which guides you here apparmor/includes.nix where you can see some of the abstractions and tunables to follow progress.

SELinux: Experimental, not fully integrated, recent progress for advanced/curious users; expect rough edges and manual intervention if you want to try it. Most find SELinux more complex to configure and maintain than AppArmor.

This isn’t meant to be a comprehensive guide, more to get people thinking about security on NixOS.

See the following guide on hardening networking:


Resources

Advanced Hardening with nix-mineral (Community Project)

✔️ Click to Expand section on `nix-mineral`

For users seeking a more comprehensive and opinionated approach to system hardening beyond the built-in hardened profile, the community project nix-mineral offers a declarative NixOS module.

nix-mineral aims to apply a wide array of security configurations, focusing on tweaking kernel parameters, system settings, and file permissions to reduce the attack surface.

  • Community Project Status: nix-mineral is a community-maintained project and is not officially part of the Nixpkgs repository or NixOS documentation. Its development status is explicitly stated as “Alpha software,” meaning it may introduce stability issues or unexpected behavior.

For detailed information on nix-mineral’s capabilities and current status, refer directly to its GitHub repository.

✔️ Click to Expand Resources

neal.codes vulnerability scan script:

nix-shell -p grype sbomnix --run '
  sbomnix /run/current-system --csv /dev/null --spdx /dev/null --cdx sbom.cdx.json;
  grype sbom.cdx.json
'

Government Resources 1st 6 come from gentoo’s Security_Handbook)