Niks to Nix | Authentication
In this article, I will document my minimal setup for access control for the various services I use.
I started wanting to run some services for a larger group of users, not just myself. Manually creating accounts and managing passwords for each service quickly becomes painful, so I needed a central place to manage users and authenticate them across all my services.
I looked at Authentik, which is a full-featured identity provider with SSO, OIDC, SAML, and a polished admin UI. However, it felt like overkill for my use case — I just need basic authentication and access control, not an enterprise IAM solution. It also requires PostgreSQL and Redis, which is more infrastructure than I want to maintain for this.
Instead, I went with Authelia as a central authentication portal and LLDAP as a lightweight LDAP directory for managing users and groups. Together they cover everything I need: a user directory, a login portal with MFA, and policy-based access control.
These services require secrets like JWT tokens and admin passwords, which is something I have been procrastinating on anyway, so now is the time to implement proper secret management. After doing a bit of digging, I decided to use sops-nix.
Secret management with sops-nix
Nix configurations are stored in the Nix store, which is world-readable. This means you can’t just put passwords and API keys directly in your configuration files. sops-nix solves this by encrypting secrets with age and decrypting them at NixOS activation time using the host’s SSH key.
The base sops configuration is a module that sets the path to the encrypted secrets file and tells sops-nix to use the host’s ed25519 SSH key for decryption:
{ hostname, ... }: {
sops = {
defaultSopsFile = ../secrets/${hostname}.yaml;
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
};
}The hostname variable is passed in from the flake, so each host gets its own secrets file.
The encrypted secrets themselves live in secrets/<hostname>.yaml.
Editing secrets
To edit the secrets for a host, I have a just recipe that runs sops:
# Edit encrypted secrets for a host
secrets host=`hostname`:
nix run nixpkgs#sops -- secrets/{{host}}.yamlThis opens the decrypted file in your editor. On save, sops re-encrypts automatically.
First-time setup
When setting up sops on a new machine, you need to export the age private key from the SSH host key once:
mkdir -p ~/.config/sops/age
sudo nix run nixpkgs#ssh-to-age -- -private-key \
-i /etc/ssh/ssh_host_ed25519_key > ~/.config/sops/age/keys.txtTo add a new host, get the age public key from its SSH key:
# From the host itself:
cat /etc/ssh/ssh_host_ed25519_key.pub | nix run nixpkgs#ssh-to-age
# Or remotely:
ssh-keyscan -t ed25519 <host> | nix run nixpkgs#ssh-to-ageThen add the key to .sops.yaml under keys: with an anchor (e.g. &hostname), and add a creation_rules entry for secrets/<hostname>.yaml.
After that, running just secrets <hostname> will create the encrypted secrets file.
LLDAP
LLDAP is a lightweight LDAP server with a web interface for managing users and groups. It is much simpler to run than a full OpenLDAP setup and provides everything we need for Authelia to authenticate against.
First, we create a dedicated system user and group for lldap. Then we define the sops secrets it needs: a JWT secret for its web interface and the admin password.
{ config, ... }: {
users.users.lldap = {
isSystemUser = true;
group = "lldap";
};
users.groups.lldap = { };
sops.secrets."lldap-jwt-secret".owner = "lldap";
sops.secrets."lldap-admin-password".owner = "lldap";
services.lldap = {
enable = true;
settings = {
ldap_base_dn = "dc=wouterjehee,dc=com";
ldap_user_dn = "admin";
ldap_user_email = "admin@example.com";
http_url = "http://localhost:17170";
jwt_secret_file = config.sops.secrets."lldap-jwt-secret".path;
ldap_user_pass_file = config.sops.secrets."lldap-admin-password".path;
force_ldap_user_pass_reset = "always";
};
};
}The owner on each secret ensures only the lldap user can read the decrypted files.
The force_ldap_user_pass_reset = "always" setting makes sure the admin password is always synced with what’s in sops, so you don’t end up with a stale password if you rotate it.
After deploying, the LLDAP web interface is available on port 17170 where you can create users and groups.
Authelia
Authelia is the authentication portal that sits in front of our services. It provides login forms, multi-factor authentication, and access control policies. It authenticates users against LLDAP over the LDAP protocol.
The configuration has two parts: the sops secrets and the Authelia settings.
{ config, ... }: {
sops.secrets = {
"authelia-jwt-secret".owner = "authelia-main";
"authelia-storage-encryption-key".owner = "authelia-main";
"authelia-session-secret".owner = "authelia-main";
};
sops.templates."authelia-ldap-password.yml" = {
owner = "authelia-main";
content = builtins.toJSON {
authentication_backend.ldap.password =
config.sops.placeholder."lldap-admin-password";
};
};Authelia needs several secrets: a JWT secret for its API, a storage encryption key for its database, and a session secret for cookies. These are passed as secret files through the NixOS module options.
The LDAP password deserves some extra attention.
Authelia needs the LLDAP admin password to bind to the LDAP server, but it expects this as a settings value rather than a file path.
We use sops.templates to generate a JSON settings file that contains the password at the right key.
The sops.placeholder syntax gets replaced with the actual decrypted secret value at activation time.
This file is then passed to Authelia through the settingsFiles option, which merges it with the main settings.
services.authelia.instances.main = {
enable = true;
secrets = {
jwtSecretFile =
config.sops.secrets."authelia-jwt-secret".path;
storageEncryptionKeyFile =
config.sops.secrets."authelia-storage-encryption-key".path;
sessionSecretFile =
config.sops.secrets."authelia-session-secret".path;
};
settingsFiles = [
config.sops.templates."authelia-ldap-password.yml".path
];
settings = {
server.address = "tcp://127.0.0.1:9091/";
authentication_backend.ldap = {
implementation = "lldap";
address = "ldap://localhost:3890";
base_dn = "dc=wouterjehee,dc=com";
user = "uid=admin,ou=people,dc=wouterjehee,dc=com";
};
storage.local.path = "/var/lib/authelia-main/db.sqlite3";
session.cookies = [{
domain = "example.com";
authelia_url = "https://auth.example.com";
}];
access_control = {
default_policy = "deny";
rules = [
{
domain = "test.example.com";
policy = "one_factor";
}
{
domain = "ldap.example.com";
policy = "two_factor";
subject = [ "group:lldap_admin" ];
}
];
};
notifier.filesystem.filename =
"/var/lib/authelia-main/notification.txt";
totp.issuer = "example.com";
};
};
}The access_control section defines the authorization policies.
The default policy is deny, so only explicitly listed domains are accessible.
test.example.com requires single-factor authentication (just a password), while ldap.example.com requires two-factor and is restricted to members of the lldap_admin group.
The authentication_backend section tells Authelia to use the lldap implementation, which knows the specific LDAP schema that LLDAP uses.
The session.cookies configuration scopes the authentication cookie to the example.com domain, so a single login works across all subdomains.
Caddy
In a previous article I used Nginx as a reverse proxy. I have since switched to Caddy because it handles TLS certificates automatically without any ACME configuration.
Caddy also makes it straightforward to add Authelia’s forward authentication using a reusable snippet:
{ ... }: {
services.caddy = {
enable = true;
extraConfig = ''
(authelia) {
forward_auth localhost:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
}
'';The authelia snippet uses Caddy’s forward_auth directive to check every request against Authelia’s authorization endpoint.
If the user is not authenticated, they get redirected to the login page.
On successful authentication, Authelia’s response headers (Remote-User, Remote-Groups, etc.) are forwarded to the upstream service, so it knows who the user is.
With this snippet defined, protecting a service is as simple as adding import authelia inside a route block:
virtualHosts = {
"auth.example.com".extraConfig = ''
reverse_proxy http://localhost:9091
'';
"ldap.example.com".extraConfig = ''
route {
import authelia
reverse_proxy http://localhost:17170
}
'';
};
};
}The Authelia portal itself at auth.example.com is a plain reverse proxy without the forward auth snippet — it is the authentication endpoint, so it doesn’t need to protect itself.
Services that need authentication, like the LLDAP web interface and the test site, import the snippet inside a route block.
Services that don’t need authentication, like the main website and the calendar, are just regular reverse proxies or file servers.