Users, Groups, and SSH Configuration

Updated

September 12, 2026

Users, Groups, and SSH Configuration

useradd on a server is an account git does not know about. The boring default is: users.mutableUsers = false, SSH keys in the flake, password hashes from mkpasswd or a file, and sshd with password auth off.

Mental model

With mutableUsers = false, every nixos-rebuild switch rewrites /etc/passwd and /etc/shadow from the config. An account that left the flake left the machine. passwd as a user may appear to work until the next switch (or be blocked). That is the feature.

Field Use
isNormalUser = true; Human: home, shell, extraGroups
isSystemUser = true; Daemon uid you named (rare if DynamicUser suffices)
extraGroups = [ "wheel" ]; sudo (if sudo is enabled)
openssh.authorizedKeys.keys Login without a password
hashedPassword Hash in the store (still not plaintext)
hashedPasswordFile Hash outside the store (better)
initialPassword Lab only. Plaintext in the store. Never production.
flake  →  users.users.deskadmin
              ├── uid/gid
              ├── ~/.ssh/authorized_keys (generated)
              └── hashedPasswordFile

services.openssh is the daemon. User keys do not start sshd. Enable the service, then disable password authentication.

Worked examples

Case 1: Immutable users and hardened sshd

Save as users_ssh.nix:

# users_ssh.nix
{
  users.mutableUsers = false;

  users.users.deskadmin = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeskAdminLaptop deskadmin@laptop"
    ];
  };

  users.users.deployer = {
    isNormalUser = true;
    description = "CI deploy key";
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleDeployerKeyCorp"
    ];
  };

  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      PermitRootLogin = "no";
      X11Forwarding = false;
    };
  };

  security.sudo.wheelNeedsPassword = true;
}

security.sudo.wheelNeedsPassword = false is convenient on a lab VM and a foot-gun on a laptop someone might walk away from. Default to needing a password (or a key + sudo timestamp) unless this is a CI image.

sudo nixos-rebuild switch
getent passwd deskadmin
sudo grep -E 'PasswordAuthentication|PermitRootLogin' /etc/ssh/sshd_config

Case 2: Hash a console password

On a trusted machine:

mkpasswd -m sha-512

Output (shape):

$6$rounds=656000$SALT$HASH…
# password_hash.nix
{
  users.users.operator = {
    isNormalUser = true;
    hashedPassword = "$6$rounds=656000$SALT$HASH";
  };
}

The hash is in the store. That is weaker than hashedPasswordFile but not the same as initialPassword = "hunter2". Anyone with store access can offline-crack the hash; they do not see the password in grep. Prefer a file:

{
  users.users.operator.hashedPasswordFile = "/persist/secrets/operator.hash";
}

Generate the file once, mode 0400, persist it (impermanence). sops-nix can decrypt it at boot.

Case 3: Root is not a daily login

# root.nix
{
  users.users.root.openssh.authorizedKeys.keys = [ ];
  services.openssh.settings.PermitRootLogin = "no";
}

Break-glass in a lab VM can use a root password hash on the console, not over SSH. Production: serial console + the previous generation, not PermitRootLogin = "yes".

Case 4: Groups for devices, not for folklore

# groups.nix
{
  users.users.deskadmin.extraGroups = [
    "wheel"
    "networkmanager" # if NM is enabled
    "video"
    "dialout"        # USB serial, lab only
  ];
}

Each group is a capability. extraGroups = [ "wheel" "docker" "libvirtd" "adbusers" "…"] on every human is how laptops become root. Add groups when a device or module requires them.

users.groups.desk.members = [ "deskadmin" "deployer" ]; for a shared directory.

Case 5: Prove password SSH is dead

From another machine:

ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deskadmin@desk-workstation

Output:

deskadmin@desk-workstation: Permission denied (publickey).

Then:

ssh -i ~/.ssh/id_ed25519 deskadmin@desk-workstation true

Output: silence, exit 0.

If password still works, PasswordAuthentication did not land (wrong settings. attr on this release) or you are not hitting this sshd.

# ssh-allow.nix fragment
{
  services.openssh.settings.AllowUsers = [ "deskadmin" "deployer" ];
  services.openssh.ports = [ 22 ];
}

AllowUsers is a second gate after keys. User systemd (loginctl enable-linger deskadmin) is not implied by isNormalUser — set users.users.deskadmin.linger = true; only if that user must run lingering systemd --user units after logout (CI agents, not laptops).

The trap

The trap is initialPassword = "changeme" left in the flake after install. The string is in /nix/store forever (and in git history). Even after you “change” it with passwd, mutableUsers = false may reset or ignore that, and the store still has changeme.

Use hashedPassword / hashedPasswordFile. Delete initialPassword from the first commit that leaves the lab.

The other trap is users.mutableUsers = true on a server “so the new hire can passwd.” They never appear in git. Six months later nobody can say who has a shell. false plus a PR that adds their key.

The boring rule

  • mutableUsers = false on anything you would call production (and on the lab VM, so you practise).
  • SSH keys in the flake. Password SSH off. Root SSH off.
  • Hashes from mkpasswd -m sha-512. Prefer hashedPasswordFile.
  • Never initialPassword outside a throwaway installer snippet.
  • wheel is sudo. Do not sprinkle extra groups “just in case.”
  • AllowUsers on sshd. linger only for user units that must outlive the session.

Try this

  1. Run mkpasswd -m sha-512, paste the hash on a lab user, switch, log in on the VM console.
  2. Add a second authorized key, switch, ssh-add -l / connect with that key. Remove the key, switch, confirm that key is refused.
  3. sudo useradd leftover on a mutableUsers = false host, then sudo nixos-rebuild switch, then getent passwd leftover. The account should be gone.
  4. nix-store --query --references /run/current-system | xargs grep -l initialPassword — should print nothing. If it prints, you still have plaintext in the closure.