When hardening a NixOS workstation or laptop, the standard advice is often to enable security.protectKernelImage = true. But if you rely on encrypted hibernation or suspend-then-hibernate on a laptop, doing so quietly breaks your sleep lifecycle.
Below is a breakdown of what that option actually changes, why it kills sleep, and the targeted flags I use instead to harden workstations without breaking daily desktop usability.
The protectKernelImage Trap
If you look up how security.protectKernelImage is implemented in nixpkgs (defined in nixos/modules/security/misc.nix), you will find it literally consists of just two lines:
# nixos/modules/security/misc.nix
(lib.mkIf config.security.protectKernelImage {
# Disable hibernation (allows replacing the running kernel)
boot.kernelParams = [ "nohibernate" ];
# Prevent replacing the running kernel image w/o reboot
boot.kernel.sysctl."kernel.kexec_load_disabled" = lib.mkDefault true;
})
That is the entire implementation. It passes nohibernate to the kernel, and disables kexec via sysctl.
The underlying security rationale for nohibernate is straightforward: resuming from an unauthenticated swap partition allows an attacker with offline disk write access to patch ring-0 memory and replace the running kernel. But if your swap partition is already hosted on an encrypted LUKS volume, that offline tampering vector is mitigated, making the blanket ban on hibernation an unnecessary loss of functionality.
The practical problem: on a laptop with full-disk encryption (LUKS), regular suspend leaves disk keys resident in plaintext in volatile RAM. suspend-then-hibernate solves this by sleeping initially for quick resume, then automatically writing RAM to encrypted swap and powering off after an inactivity timeout (e.g. 1 hour).
If nohibernate is in your kernel parameters, systemd will suspend, wait for the timeout, attempt to hibernate, fail, and wake right back up with fans spinning in your bag.
Instead of using the toggle, we can set kernel.kexec_load_disabled = 1 directly in sysctls and keep hibernation intact.
Targeted Kernel Sysctls
These are the baseline sysctl restrictions I apply across all machines in modules/nixos/hardening.nix:
boot.kernel.sysctl = {
# Hide kernel pointer addresses from unprivileged users (/proc/kallsyms)
"kernel.kptr_restrict" = 2;
# Restrict dmesg buffer access to root
"kernel.dmesg_restrict" = 1;
# Prevent loading a new kernel via kexec without a full reboot
"kernel.kexec_load_disabled" = 1;
# Restrict eBPF to root and harden JIT compiler against spray attacks
"kernel.unprivileged_bpf_disabled" = 1;
"net.core.bpf_jit_harden" = 2;
# Prevent processes from ptracing non-child processes (Yama LSM)
"kernel.yama.ptrace_scope" = 1;
};
These flags restrict address space leaks, stop local privilege escalation chains that rely on unprivileged eBPF, and prevent unauthorized memory dumping between user processes, all without impacting desktop software like Steam, PipeWire, or browser sandboxes.
DMA Isolation: IOMMU & Thunderbolt
Direct Memory Access (DMA) attacks allow external peripherals on high-speed buses (Thunderbolt, USB4, PCIe) to read physical RAM directly, bypassing OS access controls.
To enforce strict hardware-level DMA isolation:
boot.kernelParams = [
"intel_iommu=on"
"amd_iommu=force_isolation"
"iommu=force"
"iommu.strict=1"
"iommu.passthrough=0"
];
# Require explicit user session authorization for Thunderbolt devices
services.hardware.bolt.enable = true;
iommu.strict=1 forces synchronous unmapping of device memory, preventing devices from accessing buffers after an I/O operation finishes. services.hardware.bolt runs the boltd daemon, ensuring external Thunderbolt docks and PCIe devices can only communicate once authorized.
Network Privacy: stable vs. random MAC
A common recommendation for Wi-Fi privacy is setting NetworkManager's MAC address policy to random.
In practice, "random" generates a new MAC every single time the interface associates. On home or office Wi-Fi, this pollutes your router's DHCP leases, breaks static IP reservations, and triggers captive portal logins repeatedly on hotel networks.
The cleaner option is stable:
networking.networkmanager.wifi.macAddress = "stable";
With stable, NetworkManager hashes the connection profile's stable-id (which defaults to the connection's UUID) together with a machine-specific secret key. Because NetworkManager automatically generates a separate connection profile for each Wi-Fi network you save, you get a unique pseudorandom MAC across different networks without churning addresses on reconnects. (If you explicitly want the derivation keyed to the network SSID across cloned profiles, NetworkManager also offers stable-ssid).
Initrd Emergency Lockdown and Auto-Reboot
In NixOS, stage-1 systemd already defaults to locking down emergency access (boot.initrd.systemd.emergencyAccess = false). When a LUKS unlock or mount operation fails, systemd locks the console rather than dropping into an unauthenticated root prompt. However, without further configuration, the machine simply hangs indefinitely at emergency.target. On classic scripted stage 1, failures halt at an interactive error prompt.
NixOS provides the native boot.panic_on_fail parameter to handle this across both initrd architectures:
- Systemd Stage 1: Nixpkgs ships an internal
panic-on-fail.servicehooked intoemergency.target. Whenboot.panic_on_failis present on the kernel command line, reaching the emergency target triggersecho c > /proc/sysrq-triggerto crash the kernel immediately. - Scripted Stage 1: The failure handler exits PID 1 with status 1, causing an immediate kernel panic.
Combined with panic=10, the kernel automatically reboots 10 seconds after the panic rather than staying powered on and stalled in the initrd:
boot.kernelParams = [
"boot.panic_on_fail"
"panic=10"
];
Important: Keep this behind an option that defaults to false. Only enable it after your LUKS keys, TPM2 enrollment, and bootloader configuration are fully tested and stable, otherwise a minor config typo will send your machine into an unrecoverable reboot loop.
The Module: modules/nixos/hardening.nix
Here is the complete, self-contained module in my configuration:
{ config, lib, ... }:
let
cfg = config.custom.system.security.hardening;
in
{
options.custom.system.security.hardening = {
enable = lib.mkEnableOption "system security hardening";
lockdownEmergencyShell = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Disable emergency rescue shells in initrd (reboots on boot failure).";
};
};
config = lib.mkIf cfg.enable {
# Hardware DMA isolation
boot.kernelParams = [
"intel_iommu=on"
"amd_iommu=force_isolation"
"iommu=force"
"iommu.strict=1"
"iommu.passthrough=0"
] ++ lib.optionals cfg.lockdownEmergencyShell [
"boot.panic_on_fail"
"panic=10"
];
boot.initrd.systemd.emergencyAccess = lib.mkIf cfg.lockdownEmergencyShell (lib.mkForce false);
# Thunderbolt / USB4 DMA device authorization
services.hardware.bolt.enable = true;
# Kernel memory and execution hardening
boot.kernel.sysctl = {
"kernel.kptr_restrict" = 2;
"kernel.dmesg_restrict" = 1;
"kernel.kexec_load_disabled" = 1;
"kernel.unprivileged_bpf_disabled" = 1;
"net.core.bpf_jit_harden" = 2;
"kernel.yama.ptrace_scope" = 1;
};
# Stable per-connection MAC address derivation
networking.networkmanager.wifi.macAddress = "stable";
};
}