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.

A directory information tree. The root entry dc=ad,dc=example,dc=com has three organizational units: ou=People, ou=Groups and ou=Computers. Under ou=People are the users cn=Okafor\, Amara and cn=Tomasz Nowak. Under ou=Groups is cn=NOC Engineers. Under ou=Computers is cn=KE-LT-0417. The distinguished name of the user Tomasz Nowak is read from the leaf up to the root. dc=ad,dc=example,dc=com ou=People ou=Groups ou=Computers cn=Okafor\, Amara cn=Tomasz Nowak cn=NOC Engineers cn=KE-LT-0417 DN: cn=Tomasz Nowak,ou=People,dc=ad,dc=example,dc=com
A small directory tree. The highlighted path gives the distinguished name of one user: each step up the tree adds one component to the right.

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).

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) or sub (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.