DevTools Hub

Search tools

Search for a developer tool

LDAP Filter Syntax Explained

Part of the Active Directory Toolkit

Every directory search — Active Directory, OpenLDAP, or anything else speaking LDAP — gets filtered down with the same underlying syntax: a fully parenthesized, prefix-notation expression defined by RFC 4515. It looks unfamiliar at first glance precisely because it avoids the operator-precedence ambiguity infix boolean expressions have, at the cost of reading less naturally. This post covers the full grammar, the escaping rules that trip people up, and the extensible-match syntax that exists for exactly the cases the basic operators can't express.

The basic shape

Every filter is a parenthesized expression: either a comparison against one attribute, or an & (AND), | (OR), or ! (NOT) operator applied to one or more nested filters, each fully parenthesized in turn:

(objectClass=user)
(&(objectClass=user)(memberOf=cn=Sales,dc=example,dc=com))
(|(cn=alice)(cn=bob))
(!(objectClass=computer))

Notice that & and | can take any number of nested filters (one or more), while ! takes exactly one — negating multiple conditions at once means wrapping them in their own & or | first, since (!(a=1)(b=2)) isn't valid syntax at all.

The five comparison forms

Within a single attribute comparison, five distinct forms exist:

  • Equality: (cn=Alice Smith) — matches an exact value.
  • Presence: (mail=*) — matches any entry where the attribute is set at all, regardless of value.
  • Substring: (cn=al*ce), (cn=ali*), (cn=*ice) — matches values starting with, ending with, or containing specific text, distinguished from equality purely by the presence of an unescaped * in the value.
  • Approximate, greater-or-equal, less-or-equal: (cn~=Smyth), (uidNumber>=1000), (uidNumber<=2000) — approximate match is server-defined (often a soundex-style comparison) rather than precisely specified by the standard.
  • Extensible match: (attr:dn:matchingRule:=value) — covered separately below, since it's the one form genuinely different from the others.

Escaping: one rule, no shortcuts

RFC 4515 defines exactly one escape mechanism: a backslash followed by exactly two hex digits representing the character's byte value. Four characters specifically need it whenever they appear literally in a value:

*  → \2a
(  → \28
)  → \29
\  → \5c

There's no backslash-asterisk shorthand the way many programming languages allow — writing \* instead of \2a is simply invalid, since the grammar only recognizes the two-hex-digit form. Get this wrong and the result usually isn't an error at all: an unescaped * silently becomes a substring wildcard instead of a literal character, matching a broader (or different) set of entries than intended without any warning.

Extensible match: the escape hatch for everything else

(userAccountControl:1.2.840.113556.1.4.803:=2)

This is Active Directory's bitwise-AND matching rule in action — checking whether a specific bit is set inside the numeric userAccountControl attribute (bit 2 specifically flags a disabled account), something no plain equality or range comparison can express. The general form is attr:dn:matchingRule:=value, where either the attribute or the matching rule can be omitted but not both, and the optional :dn flag extends the match to also search through DN-valued attributes rather than just the named attribute itself.

A realistic combined example

(&(objectCategory=person)(objectClass=user)
  (memberOf=cn=Sales,dc=example,dc=com)
  (!(userAccountControl:1.2.840.113556.1.4.803:=2)))

Read outward: this is an AND of four conditions — a person object, specifically a user, a member of the Sales group, and (via the wrapped NOT) not a disabled account. Every one of those four sub-filters is independently valid syntax on its own; the outer & is what ties them together into a single combined search.

Common real-world filters

A handful of filters cover most day-to-day Active Directory searches, and they're worth having memorized rather than reconstructed from scratch each time:

# All disabled user accounts
(&(objectCategory=person)(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=2))

# All locked-out accounts
(&(objectCategory=person)(objectClass=user)(lockoutTime>=1))

# Direct members of a specific group (not nested/transitive)
(memberOf=cn=Sales,ou=Groups,dc=example,dc=com)

# Computers, not users
(objectCategory=computer)

# Accounts whose password never expires
(userAccountControl:1.2.840.113556.1.4.803:=65536)

The disabled-account and password-never-expires filters both use the same bitwise-AND extensible match against userAccountControl, just testing different bits — 2 for the disabled flag, 65536 for the password-never-expires flag. memberOf only reflects direct membership; finding everyone effectively in a group through nested membership requires either the LDAP_MATCHING_RULE_IN_CHAIN extensible match (1.2.840.113556.1.4.1941) or walking the nesting chain in application code.

A filter is only half the query: scope matters too

A filter alone doesn't determine what gets searched — every LDAP search also specifies a base DN (where in the directory tree to start) and a scope: base (only the base DN itself, useful for checking one specific object), onelevel (immediate children of the base DN only, not their descendants), or subtree (the base DN and everything beneath it, recursively — the default in most tools and the one people usually mean). The same filter run with different scope or a different base DN returns entirely different result sets, which is a common source of confusion when a filter that works correctly in one context (a specific OU) appears to "stop matching" when reused with a different base DN or a narrower scope than intended.

Wildcards and indexing performance

Not all substring filters cost the same to evaluate. A trailing wildcard like (cn=smith*) can typically use an index on that attribute, since the directory can jump straight to entries starting with "smith" and stop scanning as soon as they stop matching. A leading wildcard like (cn=*smith) generally can't — there's no way to index "ends with" efficiently the same way, so the directory typically falls back to scanning every entry. This matters specifically for scripts or applications that run the same filter frequently against a large directory; a filter that's syntactically fine can still be meaningfully slower than a differently-shaped one returning the same logical results.

Common mistakes

  • Writing \* instead of \2a. Not a recognized escape — the grammar only defines the two-hex-digit form.
  • Wrapping more than one condition in a single NOT. ! takes exactly one filter; combine conditions with & or | first.
  • Confusing presence with a non-empty check. (attr=*) tests whether the attribute exists on the entry at all, not whether some other field is logically empty.
  • Assuming an attribute name is valid because the filter parses. Syntax validation and schema validation are different things — a syntactically valid filter referencing a nonexistent attribute simply never matches anything.

FAQ

Why is LDAP filter syntax prefix notation instead of the usual infix style?

Prefix notation with mandatory parentheses around every group removes any ambiguity about operator precedence — there's no equivalent question to "does AND bind tighter than OR here" the way there is in infix boolean expressions, since every grouping is explicit. The tradeoff is that it reads unnaturally to anyone used to conventional boolean syntax.

What actually happens if I forget to escape a special character?

It depends on the character and where it lands. An unescaped * in a value context is silently interpreted as a substring wildcard rather than a literal asterisk — no error, just a filter matching something different than intended. An unescaped ( or ) breaks the filter's structure outright, since those characters are how the parser finds group boundaries.

Is (uid=*) the same as checking whether a field is not empty?

It's checking whether the attribute is present on the entry at all, which is subtly different from "not empty" in the way a spreadsheet might mean it. An attribute either has one or more values or doesn't exist on that entry — there's no LDAP concept of an attribute existing with an explicitly empty value.

Why would anyone use an extensible match instead of a plain equality filter?

The most common real reason is a matching rule OID like Active Directory's LDAP_MATCHING_RULE_BIT_AND (1.2.840.113556.1.4.803), which lets a filter test individual bits inside a numeric attribute like userAccountControl — something a plain equality or range comparison genuinely can't express, since it needs a specific comparison semantics beyond "equals" or "greater than."

Can I combine a substring wildcard with an extensible match?

No — extensible match syntax (attr:dn:matchingRule:=value) doesn't support wildcards inside its value the way a plain equality filter does; the value is compared using whatever semantics the matching rule itself defines, which typically expects an exact value rather than a pattern.

Try it yourself

LDAP Filter Parser & Validator parses any filter into a plain-English breakdown and flags escaping and structural mistakes, entirely in your browser.

Related tools