Basic auth in nginx means htpasswd. A file of users and hashes, copied to every server, updated by hand, and forgotten the day somebody leaves. The usual way out is auth_request: nginx asks a second service, that service talks to your directory, and now you run a sidecar, keep it alive, and pay one extra HTTP round trip per request.
nginx-auth-ldap-module does the lookup in nginx. The credentials from the Authorization header go to your LDAP or Active Directory server, the answer decides the request, and nothing else is involved. The handler sits in the access phase, so a refused request never reaches the content phase.
The smallest configuration that works
One block describes the directory, one location uses it:
http {
ldap_server directory {
url "ldap://ldap.example.internal/ou=people,dc=example,dc=internal?uid?sub";
binddn "cn=nginx,ou=services,dc=example,dc=internal";
binddn_passwd "secret";
require valid_user;
}
server {
location /staff/ {
auth_ldap "staff only";
auth_ldap_servers directory;
}
}
}
The url carries scheme, host, base DN, the attribute a login name is matched against, the scope, and optionally a filter that narrows the search further. ?uid?sub means a login name is compared against uid anywhere below the base. For Active Directory it is usually ?sAMAccountName?sub.
binddn is the account the search runs as. Leave it out and the search is anonymous, which most directories refuse. The password does not belong in your main configuration, so there is include:
ldap_server directory {
url "ldap://ldap.example.internal/ou=people,dc=example,dc=internal?uid?sub";
binddn "cn=nginx,ou=services,dc=example,dc=internal";
include ldap-password.conf;
require valid_user;
}
require valid_user lets in any account that binds. That is the weakest rule the module has, and for an intranet behind a VPN it is often all you want.
Deciding who gets in
There are four require rules, and they compose. Two of them in a block:
ldap_server directory {
url "...";
binddn "...";
include ldap-password.conf;
group_attribute member;
group_attribute_is_dn on;
require group "cn=staff,ou=groups,dc=example,dc=internal";
require user "uid=deploy,ou=services,dc=example,dc=internal";
satisfy any;
}
satisfy any, the default, means one rule is enough: staff, or the deploy account. satisfy all means every rule has to hold.
group_attribute has no default, and that is deliberate. Directories disagree: OpenLDAP with groupOfNames uses member, with groupOfUniqueNames it is uniqueMember, Active Directory uses member. A require group rule without the attribute refuses every request rather than guessing.
group_attribute_is_dn on says the group lists its members as full distinguished names. Off, the default, says it lists login names. Get this wrong and every membership check fails, because the module compares the wrong string.
The fourth rule is the interesting one:
require valid_user "uid=$remote_user,ou=people,dc=example,dc=internal";
Here the login name goes into a template and the result is bound directly. No search at all. If your directory has one flat tree and predictable DNs, this is one round trip instead of two, and the service account becomes unnecessary.
Active Directory
Three things differ from OpenLDAP.
The login attribute is sAMAccountName, so the url ends ?sAMAccountName?sub. The bind account is often written as user@domain.internal rather than as a distinguished name, and AD accepts that:
ldap_server ad {
url "ldaps://dc01.example.internal/ou=Users,dc=example,dc=internal?sAMAccountName?sub";
binddn "nginx@example.internal";
include ldap-password.conf;
group_attribute member;
group_attribute_is_dn on;
require group "cn=Webaccess,ou=Groups,dc=example,dc=internal";
}
The third difference is the one to plan around. Membership is checked at the group object, with a filter of the shape
(&(cn=Webaccess)(member=cn=Alice,ou=Users,dc=example,dc=internal))
That is a plain equality test against the member attribute, so a user who is only a member through a nested group does not match. AD can answer that question, with the matching rule 1.2.840.113556.1.4.1941, but this module does not use it. If your groups are nested, name the group the user is actually in, or flatten the one you check against.
Rules that change per request
A rule may hold a variable:
require group "cn=$http_x_group,ou=groups,dc=example,dc=internal";
The request then says which group it wants to be checked against, which is useful behind a gateway that already knows the tenant. It is also worth knowing exactly what is allowed there, because a value from a header ends up in an LDAP filter.
The value is escaped before it goes into a filter, and the whole rule has to parse as a distinguished name or the request is refused. Where the variable stands inside the name, as above, a comma it brings would add a relative name nobody wrote, and that is refused. Where the variable ends the rule:
require group "cn=staff,$http_x_base";
the tail of the name is the variable’s to give, so the request chooses the base the group is searched under. That is what putting a variable in that position says, so it is allowed.
A server with such a rule is answered without the cache, because the cache key is the user, the password and the server, and cannot see a rule that is decided per request.
LDAPS, and what verification actually means
ldaps:// alone encrypts and verifies nothing. Three settings decide:
ldap_server directory {
url "ldaps://ldap.example.internal/ou=people,dc=example,dc=internal?uid?sub";
ssl_check_cert on;
ssl_ca_file /usr/local/etc/ssl/internal-ca.pem;
ssl_crl_file /usr/local/etc/ssl/internal-ca.crl;
...
}
| value | what is checked |
|---|---|
off (default) | nothing; the ssl_ca_* values are not even read |
chain | the chain verifies against the named authorities |
on | the chain verifies and the certificate names the host |
With on, the name has to appear in a subject alternative name. Not in the common name. RFC 9525 says that is where a name belongs, and a partial wildcard such as ldap*.example.internal is refused. If your directory certificate names the host only in its CN, or you reach it by address, use chain.
A block that verifies gets a certificate store of its own, holding the authorities it names and nothing else. Two ldap_server blocks with different ssl_ca_file values do not share trust, which is the point of naming them separately. Name none, and the system trust store is used.
ssl_ca_dir is read the way OpenSSL reads a directory, by hash, so it needs the symbolic links c_rehash leaves behind. A directory full of certificates without those links trusts nothing, silently, which is a good way to spend an afternoon.
ssl_crl_file holds the certificate of the directory against a revocation list. Only that certificate, not the authorities above it: OpenSSL would then want a list for every link in the chain, and one authority that publishes none would make the whole chain fail.
Connections, and what happens under load
Every worker keeps its own connections to the directory, opened when the worker starts:
connections 4;
connect_timeout 10s;
bind_timeout 5s;
request_timeout 10s;
reconnect_timeout 10s;
max_down_retries 2;
connections defaults to 1. A request that finds the connection busy waits for it rather than opening another, so with the default that means queueing behind the request before it. On a login page that nobody hits twice a second this is fine. Behind an API it is not, and raising this is the first thing to do when authentication feels slow under load.
max_down_retries is for a directory that drops connections: it says how often a dropped connection is rebuilt immediately instead of waiting out reconnect_timeout. It is counted per connection, so one that keeps failing does not spend the allowance of the others.
A request the module cannot decide is answered 500. No directory answered, none could be reached, or a certificate was refused. Note the last one: a directory whose certificate fails has no usable connection, so the request waits out request_timeout before it gets that 500. If authentication hangs for exactly ten seconds and then fails, look at your certificate before you look at the network.
The cache
auth_ldap_cache_enabled on;
auth_ldap_cache_expiration_time 60s;
auth_ldap_cache_size 10000;
The key is the user, the password and the server, so a cached allow is only reused for the same credentials against the same directory. The size is in entries and is rounded up to a prime bucket count internally; ask for more than the table can hold and the module says so in the error log rather than capping it quietly.
Sixty seconds is a reasonable starting point. It bounds how long a disabled account still gets in, which is the only thing this setting really trades.
Following a directory that moves
By default the host in a url is resolved once, when the configuration is read. For a directory behind a load balancer whose addresses change, that is not enough:
auth_ldap_resolver 192.168.1.1 valid=30s;
auth_ldap_resolver_timeout 10s;
With a resolver configured, the name is asked again before every connection, so a directory that moved is reached without a reload.
When it does not work
Most of the time the configuration is accepted and requests are simply refused, which tells you nothing. The error log at info already carries the useful lines:
http_auth_ldap: Connecting to LDAP server "ldap://ldap.example.internal"
http_auth_ldap: Could not find user DN
Could not find user DN means the search ran and matched nothing: wrong base DN, wrong login attribute, or a bind account that may not read that subtree.
At debug the module prints the filters it builds, and that is usually where the answer is:
error_log /var/log/nginx/error.log debug;
http_auth_ldap: Search group filter is "(&(cn=staff)(member=uid=alice,ou=people,dc=example,dc=internal))"
http_auth_ldap: Comparing user DN with "uid=alice,ou=people,dc=example,dc=internal"
Seeing the filter answers both of the questions that cost the most time: is group_attribute the one your directory uses, and is group_attribute_is_dn the right way round. A filter with (member=alice) where the group lists DNs, or the other way round, never matches and looks exactly like a permission problem.
Authentication timed out is not a slow directory. It is the request giving up after request_timeout, which is also what you get when the certificate was refused and no connection was ever usable.
A debug build of nginx is needed for the debug lines. The FreeBSD ports build with --with-debug by default.
On FreeBSD
The module is an option on the nginx ports, and the sources come from their own port, so nothing is fetched at build time:
# make -C /usr/ports/www/nginx config # tick HTTP_AUTH_LDAP
# make -C /usr/ports/www/nginx install clean
It is available on www/nginx, www/nginx-devel and www/freenginx alike. Built as a loadable module by default, so the usual load_module line applies if you build with DSO on and are assembling your own configuration.
What it does not do
It is HTTP basic authentication. There is no form login, no session cookie, no SSO: the browser sends credentials with every request, which is why the cache matters. Kerberos or SAML are different modules.
It authenticates and authorises. It does not hand the user’s attributes to your application, beyond $remote_user being set to the login name.
And it speaks to one directory per ldap_server block. Several blocks listed in auth_ldap_servers are tried in order, which covers a replica, but not a single logical directory split across hosts.
Where it is
The sources, the full directive reference and the test suite: github.com/sysadmin-labs/nginx-auth-ldap-module
The FreeBSD port, with its package state, its CVE status and the branches it is in: www/nginx-module-auth-ldap on portstatus
BSD 2-Clause.