LDAP basics: Base DN, Search Filters, and a test directory in 5 minutes

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.orgBase 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. 🌳

This entry was posted in LDAP and Active Directory. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *


× 4 = four