Directory Services and LDAP
How directories store people, groups and devices: the directory tree, distinguished names, object classes and attributes, the schema, and how applications query a directory with LDAP.
What a directory is
A directory service stores information about the objects in an organization (people, groups, computers, printers, applications) and lets other systems look them up and authenticate against them. When you sign in to a Windows PC in a domain, when a mail server finds the mailbox for an address, or when a Linux server checks whether a user may log in, a directory answers.
A directory is a kind of database, but with a different design goal than the relational databases described in What Is a Database?:
| Aspect | Directory | Relational database |
|---|---|---|
| Workload | Very many small reads (who is this, which groups), few writes | Reads and writes, often heavy writes |
| Structure | A tree of entries with attributes, many multi-valued | Tables of rows and columns, related by keys |
| Consistency | Replicated to many servers; a change may take seconds to minutes to reach all of them | Transactions that are immediately consistent |
| Access | One standard protocol, LDAP, for every client | SQL, with product-specific drivers |
| Typical data | Accounts, groups, devices, credentials, policies | Orders, invoices, measurements |
That is why you never store fast-changing business data in a directory, and why a directory is the natural identity store: identities change rarely but are looked up constantly.
LDAP, the protocol
The Lightweight Directory Access Protocol (LDAP) is the open standard for talking to a directory over a network. It started in the 1990s as a lighter alternative to the X.500 directory access protocol and kept X.500's data model: a tree of entries with typed attributes. The current version, LDAPv3, is defined in a set of RFCs published in June 2006: RFC 4510 (the roadmap), RFC 4511 (the protocol), RFC 4513 (authentication and security) and RFC 4514 (how distinguished names are written as strings), among others.
Almost every directory speaks LDAP: Active Directory, OpenLDAP, 389 Directory Server, FreeIPA and the LDAP interfaces of cloud identity services. Applications that "integrate with AD" usually do so with LDAP: a VPN gateway, a printer that looks up email addresses, a wiki that checks group membership.
The directory information tree
The entries of a directory form a tree, the
directory information tree
(DIT). The top of an Active Directory domain is named after its DNS
name: the domain ad.example.com becomes
dc=ad,dc=example,dc=com, where dc stands
for domain component. Below it, containers and organizational units
(OUs) group the entries, and leaf entries are the users, groups and
computers themselves.
The tree is not only for tidiness. Permissions and policies are usually attached to a branch and inherited by everything below it, and searches start at a branch. A well-planned tree therefore follows how the organization is administered (see organizational units in Active Directory).
Distinguished names
Every entry has a unique distinguished name (DN): its full path in the tree, written from the entry itself up to the root, with commas between the steps. Each step is a relative distinguished name (RDN): one attribute and its value, unique among the entry's siblings. In
cn=Mei Chen,ou=People,dc=ad,dc=example,dc=com
the RDN is cn=Mei Chen (cn is common name), and the
parent entry is ou=People,dc=ad,dc=example,dc=com.
Attribute names are case-insensitive, so CN=, as Active
Directory displays it, and cn= mean the same. Two
entries may share a common name as long as they live in different
branches; that is also why moving or renaming an entry changes its
DN, and why applications should store a stable identifier (such as
the objectGUID in AD) rather than the DN.
Special characters and escaping
Some characters have a meaning in a DN: the comma separates RDNs,
the plus sign joins multi-valued RDNs, and so on. RFC 4514 says that
a value containing , + " \ < > ; must escape them
with a backslash, as must a leading # or space, a
trailing space, and a NUL character. Other characters can also be
written as \ plus two hexadecimal digits per UTF-8
byte. Many organizations name user objects "Last, First", so commas
in DNs are common, and scripts that split DNs on every comma break
on the first such user.
The example escapes a value, builds a DN, and splits it back into RDNs while respecting escapes:
SPECIAL = ',+"\\<>;'
HEX = "0123456789abcdefABCDEF"
def escape_value(value):
"""Escape an attribute value for use in a DN (RFC 4514, section 2.4)."""
out = []
for i, ch in enumerate(value):
if ch in SPECIAL:
out.append("\\" + ch)
elif ch == "\0":
out.append("\\00")
elif ch == "#" and i == 0:
out.append("\\#")
elif ch == " " and (i == 0 or i == len(value) - 1):
out.append("\\ ")
else:
out.append(ch)
return "".join(out)
def split_rdns(dn):
"""Split a DN on commas that are not escaped with a backslash."""
parts, current, i = [], "", 0
while i < len(dn):
if dn[i] == "\\":
current += dn[i : i + 2] # keep the escape and the next character
i += 2
elif dn[i] == ",":
parts.append(current)
current = ""
i += 1
else:
current += dn[i]
i += 1
parts.append(current)
return parts
def unescape(value):
"""Undo \\, style escapes and \\XX hex escapes (UTF-8 bytes)."""
raw, i = bytearray(), 0
while i < len(value):
pair = value[i + 1 : i + 3]
if value[i] == "\\" and len(pair) == 2 and all(c in HEX for c in pair):
raw.append(int(pair, 16))
i += 3
elif value[i] == "\\":
raw += value[i + 1].encode()
i += 2
else:
raw += value[i].encode()
i += 1
return raw.decode("utf-8")
def parse_dn(dn):
"""Return the RDNs of a DN as (attribute, value) pairs, leaf first."""
pairs = []
for rdn in split_rdns(dn):
attr, value = rdn.split("=", 1)
pairs.append((attr.strip(), unescape(value)))
return pairs
dn = "cn=" + escape_value("Okafor, Amara") + ",ou=People,dc=ad,dc=example,dc=com"
print(dn)
for attr, value in parse_dn(dn):
print(f" {attr:2} = {value}")
rdns = split_rdns(dn)
print("RDN: ", rdns[0])
print("Parent:", ",".join(rdns[1:]))
for tricky in ["#1 Printer", " Lead space", "R&D; Lab", "Ana <contractor>"]:
print(f"{tricky!r:20} -> {escape_value(tricky)}")
print(parse_dn(r"cn=J\C3\BCrgen M\C3\BCller,ou=People")[0])
cn=Okafor\, Amara,ou=People,dc=ad,dc=example,dc=com
cn = Okafor, Amara
ou = People
dc = ad
dc = example
dc = com
RDN: cn=Okafor\, Amara
Parent: ou=People,dc=ad,dc=example,dc=com
'#1 Printer' -> \#1 Printer
' Lead space' -> \ Lead space
'R&D; Lab' -> R&D\; Lab
'Ana <contractor>' -> Ana \<contractor\>
('cn', 'Jürgen Müller')
In production code, use the escaping functions of your LDAP library rather than your own; this sketch ignores multi-valued RDNs and some edge cases. The same goes for search filters, which have their own escaping rules (RFC 4515), covered below.
Object classes and attributes
An entry is a set of attributes, each with one or more values. Which
attributes an entry must and may have is decided by its
object classes, listed in the
multi-valued objectClass attribute. Object classes
inherit from each other. A user in Active Directory has the chain
top → person → organizationalPerson → user, and each
class adds attributes. There are three kinds of class:
-
Structural classes define what the entry is
(
user,group,organizationalUnit). Every entry has one structural chain. -
Auxiliary classes add extra attributes to entries
of other classes, such as
posixAccount, which gives an entry a Unix user ID and home directory. -
Abstract classes, such as
top, exist only to be inherited from.
A user entry, written in the LDAP Data Interchange Format (LDIF), the plain-text format for exporting and importing entries:
dn: cn=Tomasz Nowak,ou=People,dc=ad,dc=example,dc=com objectClass: top objectClass: person objectClass: organizationalPerson objectClass: user cn: Tomasz Nowak givenName: Tomasz sn: Nowak sAMAccountName: t.nowak userPrincipalName: t.nowak@ad.example.com mail: t.nowak@example.com department: NOC title: Network Engineer memberOf: cn=NOC Engineers,ou=Groups,dc=ad,dc=example,dc=com memberOf: cn=VPN-Users,ou=Groups,dc=ad,dc=example,dc=com
Notice that memberOf has two values: multi-valued
attributes are normal in a directory. In AD,
memberOf is not stored on the user at all; it is a
calculated back-link of the member attribute of each
group, and it does not show the user's primary group (normally
Domain Users).
The schema
The directory schema is the set of definitions of every object class and attribute the directory accepts: for each attribute its syntax (string, number, DN, binary, time), whether it is single- or multi-valued, and a globally unique object identifier (OID); for each class its parent and its required and optional attributes. The server rejects entries that break the schema, just as a database rejects a row that does not fit the table.
Products extend the schema to store their own data: installing Exchange Server or Configuration Manager in an AD forest adds classes and attributes. In Active Directory the schema is shared by the whole forest, only members of Schema Admins can change it, and additions cannot be deleted afterward (only deactivated). Schema extensions are therefore planned and tested changes, not something to click through.
LDAP operations
RFC 4511 defines a small set of operations. A client opens a connection, binds, performs operations, and unbinds.
| Operation | What it does |
|---|---|
| Bind | Authenticates the connection |
| Search | Finds entries and returns selected attributes |
| Compare | Tests whether an entry has a given attribute value |
| Add, Delete | Creates or removes one entry |
| Modify | Adds, replaces or deletes attribute values of an entry |
| Modify DN | Renames an entry or moves it to another branch |
| Extended | Additional operations, such as StartTLS or a password change |
| Unbind, Abandon | Closes the connection; cancels a running operation |
Binding
An LDAP bind is the operation that authenticates an LDAP connection; everything after it runs with the permissions of the bound identity. There are three kinds:
- Anonymous bind: no credentials. Some directories allow anonymous reads of public data; Active Directory allows almost nothing anonymously by default, and that is how it should stay.
- Simple bind: a DN (or, in AD, a user principal name) and a password, sent as they are. Over plain LDAP that means the password crosses the network readable by anyone who can capture traffic.
- SASL bind (Simple Authentication and Security Layer): a framework for stronger methods. With GSSAPI the client authenticates with Kerberos, which is what Windows computers do against AD, and the session can be signed and encrypted; EXTERNAL uses a TLS client certificate.
Applications that look users up typically bind with a dedicated, read-only service account. Give it only the read rights it needs, and protect its password like any other credential (see Identity for Applications and Machines).
Searching
A search request has four main parts:
-
Base DN: where in the tree to start, for example
ou=People,dc=ad,dc=example,dc=com. -
Scope:
base(only the base entry itself),one(its direct children) orsub(the base and everything below it). - Filter: which entries match (next section).
- Attributes: which attributes to return. Ask only for what you need; returning every attribute of thousands of entries is slow.
Servers limit how many entries one search may return (Active
Directory returns at most 1,000 entries per search, or per page, set
by
MaxPageSize); clients use the paged results control to
read larger result sets in pages.
Search filters
LDAP filters are written in prefix notation: the operator comes first, and every part sits in its own parentheses.
| Filter | Matches |
|---|---|
(department=NOC) |
Equality: department is NOC |
(mail=*) |
Presence: the entry has a mail attribute |
(cn=NOC*) |
Substring: common name starts with NOC |
(&(a=1)(b=2)) |
AND: both conditions |
(|(a=1)(b=2)) |
OR: at least one condition |
(!(a=1)) |
NOT: the condition is false |
(whenCreated>=20260901000000.0Z) |
Greater or equal, here a date in LDAP time format |
To see how a server evaluates a filter, this example parses simple filters into a tree and tests a handful of entries, held as dictionaries of multi-valued attributes. Like Active Directory, it compares attribute names and values case-insensitively:
def parse(text, i=0):
"""Parse one filter starting at text[i] == '('; return (tree, next index)."""
assert text[i] == "(", f"expected ( at {i}"
op = text[i + 1]
if op in "&|!":
children, i = [], i + 2
while text[i] == "(":
child, i = parse(text, i)
children.append(child)
return (op, children), i + 1 # skip the closing )
end = text.index(")", i)
attr, value = text[i + 1 : end].split("=", 1)
return ("=", attr.lower(), value.lower()), end + 1
def matches(tree, entry):
"""Evaluate a parsed filter against one entry (case-insensitive, like AD)."""
kind = tree[0]
if kind == "&":
return all(matches(child, entry) for child in tree[1])
if kind == "|":
return any(matches(child, entry) for child in tree[1])
if kind == "!":
return not matches(tree[1][0], entry)
_, attr, wanted = tree
values = [str(v).lower() for v in entry.get(attr, [])]
if wanted == "*":
return bool(values) # presence: the attribute exists
if "*" in wanted: # substring: "net*" or "*ops"
start, _, end = wanted.partition("*")
return any(v.startswith(start) and v.endswith(end) for v in values)
return wanted in values
entries = [
{"cn": ["Tomasz Nowak"], "objectclass": ["top", "person", "user"],
"department": ["NOC"], "mail": ["t.nowak@example.com"]},
{"cn": ["Emeka Obi"], "objectclass": ["top", "person", "user"],
"department": ["NOC"]},
{"cn": ["Mei Chen"], "objectclass": ["top", "person", "user"],
"department": ["Service Desk"], "mail": ["m.chen@example.com"]},
{"cn": ["NOC Engineers"], "objectclass": ["top", "group"],
"department": ["NOC"]},
]
filters = [
"(&(objectClass=user)(department=NOC))",
"(&(objectClass=user)(!(mail=*)))",
"(|(department=Service Desk)(cn=NOC*))",
]
for f in filters:
tree, _ = parse(f)
hits = [e["cn"][0] for e in entries if matches(tree, e)]
print(f"{f:40} -> {hits}")
(&(objectClass=user)(department=NOC)) -> ['Tomasz Nowak', 'Emeka Obi'] (&(objectClass=user)(!(mail=*))) -> ['Emeka Obi'] (|(department=Service Desk)(cn=NOC*)) -> ['Mei Chen', 'NOC Engineers']
Real servers also use indexes, so a search on an indexed attribute
does not have to read every entry, and they support more operators
(approximate match, extensible matching rules). Values that come
from user input must be escaped before they go into a filter:
*, (, ), \ and
NUL become \2a, \28, \29,
\5c and \00 (RFC 4515). Otherwise a user
who types * as their user name changes the meaning of
the query, a form of
LDAP injection.
Ports and encryption
| Port | Service | Encryption |
|---|---|---|
| 389 (TCP, UDP) | LDAP | None, unless upgraded with StartTLS or SASL |
| 636 (TCP) | LDAPS | TLS from the first byte |
| 3268 (TCP) | Global catalog (Active Directory) | As port 389 |
| 3269 (TCP) | Global catalog over TLS | As port 636 |
The global catalog is a forest-wide, partial copy of Active Directory, explained in Active Directory Fundamentals. Other well-known ports are listed in TCP, UDP and Ports.
LDAPS and StartTLS
There are two ways to wrap LDAP in Transport Layer Security (TLS), the same protocol that protects HTTPS (see PKI and Certificates):
- LDAPS (LDAP over TLS, sometimes called LDAP over SSL) uses a separate port, 636, and starts the TLS handshake as soon as the connection opens, like HTTPS does.
- StartTLS connects to the normal port 389 and then sends an extended operation that upgrades the existing connection to TLS before the bind.
Both give the same protection once TLS is running. Both also need a valid server certificate on every domain controller or LDAP server, trusted by the clients and with the server's name in it; an expired or mismatched certificate is the usual reason why LDAPS "suddenly" stops working. A client configured for StartTLS must refuse to continue if the upgrade fails, or an attacker in the middle can simply strip it.
LDAP signing and channel binding
Active Directory has two further protections against tampering and relay attacks on LDAP:
- LDAP signing requires that a SASL session be signed, so traffic cannot be altered in transit. When a domain controller requires signing, it also rejects simple binds on connections that are not protected by TLS.
- Channel binding ties the authentication inside an LDAPS session to that specific TLS connection, so credentials captured in one session cannot be relayed into another.
Defaults changed over time. On Windows Server 2019 and earlier, signing was optional and channel binding was set to "Never". On Windows Server 2025, a new deployment requires LDAP signing by default and sets channel binding to "When supported"; domain controllers upgraded in place keep their existing settings. Before tightening an existing domain, find out which clients would break: domain controllers log events 2886 to 2889 for unsigned and simple binds, and 3039 to 3041 for channel binding.
Querying a directory with ldapsearch
ldapsearch, from the OpenLDAP client tools, is the
standard command-line LDAP client on Linux and macOS. This query
binds over LDAPS as a read-only service account (-x
selects a simple bind), prompts for its password (-W)
instead of putting it on the command line, and asks for two
attributes of every NOC user:
ldapsearch -x -H ldaps://dc01.ad.example.com \
-D "svc-ldap-read@ad.example.com" -W \
-b "ou=People,dc=ad,dc=example,dc=com" -s sub \
"(&(objectCategory=person)(objectClass=user)(department=NOC))" \
cn mail
Enter LDAP Password: # extended LDIF # # LDAPv3 # base <ou=People,dc=ad,dc=example,dc=com> with scope subtree # filter: (&(objectCategory=person)(objectClass=user)(department=NOC)) # requesting: cn mail # # Tomasz Nowak, People, ad.example.com dn: CN=Tomasz Nowak,OU=People,DC=ad,DC=example,DC=com cn: Tomasz Nowak mail: t.nowak@example.com # Emeka Obi, People, ad.example.com dn: CN=Emeka Obi,OU=People,DC=ad,DC=example,DC=com cn: Emeka Obi # search result search: 2 result: 0 Success # numResponses: 3 # numEntries: 2
Read it from the bottom: result: 0 Success means the
search worked; two entries were found. A user without a
mail attribute simply shows no mail line.
On Windows, the PowerShell ActiveDirectory module (Get-ADUser -LDAPFilter) and the graphical ldp.exe run the same kind of
query.