If you’ve ever pasted an LDAP query into a config file without really understanding what each piece means, this post is for you. We’ll cover the two concepts that trip people up most — Base DN and Search Filter — then spin up a real LDAP server in Docker and run a few queries against it. By the end you’ll be able to read (and write) most LDAP queries you encounter in the wild. 🗂️
The mental model
An LDAP directory is a tree. Every entry has a unique Distinguished Name (DN) — its full path from the root, written right-to-left, comma-separated. For example:
1 | uid=alice,ou=people,dc=example,dc=org |
This says: an entry with uid=alice, inside the people Organisational Unit, inside the example.org domain. Each piece is a Relative Distinguished Name (RDN); the whole chain is the DN. Common pieces:
- dc = domain component (for example.org: dc=example,dc=org)
- ou = organisational unit (a folder, basically — ou=people, ou=groups)
- cn = common name (a person’s name, a group name)
- uid = user identifier (login name, on most Unix-style directories)
Base DN — “where to start looking”
The Base DN is the node in the tree where your search starts. Think of it as cd-ing into a directory before running find. Pick it well and you skip work; pick it badly and you scan the whole tree (or worse, you miss what you wanted).
- dc=example,dc=org — search the entire directory
- ou=people,dc=example,dc=org — only search inside the people branch
- uid=alice,ou=people,dc=example,dc=org — start at exactly one entry (useful with scope=base; see below)
Combined with the Base DN, the scope tells the server how deep to go: base (just that one node), one (immediate children only), or sub (the whole subtree). sub is the default in most clients.
Search Filter — “which entries to return”
Search Filters are RFC 4515 expressions wrapped in parentheses. The basic shape is (attribute=value). Some examples:
- (uid=alice) — exact match
- (cn=A*) — common name starts with “A”
- (objectClass=person) — every entry of class person
- (mail=*) — has any value for mail (i.e. attribute is present)
Combine filters with the prefix-notation operators & (AND), | (OR), ! (NOT). The operator goes first, then the sub-filters, all wrapped in their own parens:
- (&(objectClass=person)(uid=alice)) — person AND uid=alice
- (|(uid=alice)(uid=bob)) — uid=alice OR uid=bob
- (&(objectClass=person)(!(uid=admin))) — every person except admin
It looks alien at first because of the prefix notation, but it’s actually consistent — once you spot that the operator always comes before its operands, the rest is just nesting.
A test directory in 5 minutes (Docker)
Reading about LDAP is a slog. Running queries against a real directory is faster. The osixia/openldap image ships an OpenLDAP server with sensible defaults and a one-line spin-up:
1 | docker run -p 389:389 -p 636:636 --name my-openldap --detach osixia/openldap:1.5.0 |
That’s it. The container boots with these defaults:
- Organisation: Example Inc.
- Domain: example.org → Base DN: dc=example,dc=org
- Admin DN: cn=admin,dc=example,dc=org, password: admin
- Port 389 (LDAP) and 636 (LDAPS) on the host
A quick note on the image: osixia/openldap is largely unmaintained as of 2021 — last tagged release is 1.5.0 — but it’s still excellent for a five-minute tutorial because it does the right thing out of the box. For long-running production use, look at bitnami/openldap or run OpenLDAP directly on a host you control.
Now load a few sample entries. Save the following as seed.ldif:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | dn: ou=people,dc=example,dc=org objectClass: organizationalUnit ou: people dn: ou=groups,dc=example,dc=org objectClass: organizationalUnit ou: groups dn: uid=alice,ou=people,dc=example,dc=org objectClass: inetOrgPerson cn: Alice Anderson sn: Anderson uid: alice mail: alice@example.org userPassword: alicepass dn: uid=bob,ou=people,dc=example,dc=org objectClass: inetOrgPerson cn: Bob Brown sn: Brown uid: bob mail: bob@example.org userPassword: bobpass dn: uid=carol,ou=people,dc=example,dc=org objectClass: inetOrgPerson cn: Carol Carter sn: Carter uid: carol mail: carol@example.org userPassword: carolpass |
Then load it with ldapadd (install ldap-utils on Debian/Ubuntu, openldap-clients on RHEL, or brew install openldap on macOS):
1 | ldapadd -x -H ldap://localhost -D "cn=admin,dc=example,dc=org" -w admin -f seed.ldif |
The flags: -x simple bind (no SASL), -H the server URL, -D the bind DN, -w the password, -f the LDIF file.
Now run some queries
List everyone in the directory:
1 | ldapsearch -x -H ldap://localhost -b "dc=example,dc=org" "(objectClass=person)" |
The -b flag is the Base DN; the quoted (objectClass=person) is the Search Filter. Notice we didn’t bind as anyone — anonymous bind is allowed by default for read.
Just the people branch, with a narrower base DN:
1 2 3 | ldapsearch -x -H ldap://localhost \ -b "ou=people,dc=example,dc=org" \ "(objectClass=inetOrgPerson)" |
Find one user, returning only their email and full name:
1 2 3 | ldapsearch -x -H ldap://localhost \ -b "dc=example,dc=org" \ "(uid=alice)" cn mail |
The trailing cn mail is the attribute list — without it, the server returns every attribute on the entry. Always specify the attributes you actually need; it’s faster and easier to read.
Wildcard prefix match — everyone whose cn starts with “A” or “B”:
1 2 3 | ldapsearch -x -H ldap://localhost \ -b "ou=people,dc=example,dc=org" \ "(|(cn=A*)(cn=B*))" cn |
Combined AND filter — every person who has a mail attribute and isn’t admin:
1 2 3 | ldapsearch -x -H ldap://localhost \ -b "dc=example,dc=org" \ "(&(objectClass=person)(mail=*)(!(cn=admin)))" cn mail |
Active Directory and dsquery
Microsoft Active Directory speaks LDAP, but adds its own conventions. The dsquery command is the Windows-side equivalent of ldapsearch:
1 | dsquery * "CN=Users,DC=myadserver,DC=com" -scope onelevel -attr objectguid proxyaddresses -limit 2000 >C:\myadserver.user.list.txt |
Unpack that the same way: “CN=Users,DC=myadserver,DC=com” is the Base DN, -scope onelevel is the scope (immediate children only), -attr objectguid proxyaddresses is the attribute list. There’s no explicit filter, so * means “all entries” — equivalent to (objectClass=*).
A few AD-specific gotchas worth knowing:
- AD typically stores users under CN=Users, not OU=People — and CN=Users is a built-in Container, not an Organisational Unit, so you can’t apply Group Policy to it. Real environments usually move users into custom OUs.
- Use sAMAccountName for the pre-Windows 2000 logon name (the most common login attribute), and userPrincipalName for the email-style login (alice@corp.example.com).
- Filter for users (and exclude computer accounts) with (&(objectCategory=person)(objectClass=user)) — using objectClass=user alone also matches computer objects.
- To find disabled accounts, you bit-test userAccountControl: (userAccountControl:1.2.840.113556.1.4.803:=2). That’s the LDAP_MATCHING_RULE_BIT_AND OID — yes, really, it’s that ugly. Welcome to AD.
Tools worth knowing
- ldapsearch / ldapadd / ldapmodify — the standard CLI tools, on the command line of every Linux box once you install ldap-utils
- Apache Directory Studio — free GUI, good for browsing the tree and visually composing filters
- JXplorer — older but still works, lighter than Directory Studio
- Windows: dsquery, Active Directory Users and Computers, ldp.exe — the built-in trio for AD
Once Base DN, Search Filter, and scope click, every LDAP query you’ll see in the wild — from a Jenkins auth config, a SSSD setup, a Keycloak federation, an old Java InitialDirContext — is just those three concepts plus a credential. Have fun. 🌳