Preventing LDAP Injection

LDAP injection is the directory-services cousin of SQL injection: an application builds a search filter or a distinguished name by concatenating user input into a string, and an attacker supplies metacharacters that change the query’s structure. Because LDAP still sits behind a large share of enterprise authentication — Active Directory, OpenLDAP, corporate SSO back-ends — an injection here often lands directly on the login path, where the payoff is an authentication bypass rather than mere data disclosure.

The grammar attackers exploit is defined by RFC 4515 for search filters and RFC 4514 for distinguished names. A filter such as (&(uid=jdoe)(userPassword=secret)) is built from parentheses, the & conjunction, and assertion values. If the uid value is taken verbatim from a form field, a username of *)(uid=* rewrites the filter into (&(uid=*)(uid=*)...) — a wildcard that matches every entry in the subtree. This guide shows how to escape filter values and DN components correctly, block the classic auth-bypass filters, validate input with allowlists, and bind with least privilege. It is part of the Injection Attack Prevention guide within Vulnerability Patterns & Web Mitigation Strategies. For the database-tier analogue, see parameterized queries for SQL and NoSQL injection, and because LDAP so often backs the login flow, see how it fits secure authentication and session architecture.

Prerequisites

  • A Node.js service using ldapjs, or a Python service using ldap3
  • A directory to test against (OpenLDAP, Active Directory, or a Docker test instance)
  • A dedicated bind service account, not a directory administrator credential
  • The escaping helpers from your LDAP library available (ldapjs filter objects, ldap3 escape_filter_chars / escape_rdn)

Expected Outcomes

  • All search-filter assertion values escaped per RFC 4515 before the filter is assembled
  • All DN components escaped per RFC 4514 when constructing distinguished names
  • Auth-bypass payloads (*, *)(uid=*, \) neutralized and logged
  • The application binding to the directory with a read-scoped, least-privilege account

Step 1: Escape Filter Assertion Values Per RFC 4515

The most common LDAP injection is in the search filter. RFC 4515 reserves five characters that must be escaped inside an assertion value: the asterisk *, the open parenthesis (, the close parenthesis ), the backslash \, and the NUL byte. Each is encoded as a backslash followed by its two-digit hexadecimal code — * becomes \2a, ( becomes \28, ) becomes \29, \ becomes \5c, and NUL becomes \00.

The diagram contrasts the escaped path, which reaches the directory as a safe literal, with the unescaped path, where a wildcard rewrites the filter.

Escaped vs Unescaped LDAP Filter Construction User input reaches a filter builder. The escaped branch turns the asterisk into a hex literal and the LDAP directory returns one authorized match. The unescaped branch leaves the asterisk as a wildcard and the directory returns all entries, an injection. User Input uid = *)(uid=* Filter Builder RFC 4515 escape? escaped unescaped Directory (safe) uid=\2a\29\28uid=\2a 0 matches, no bypass Directory (injected) wildcard matches ALL entries — auth bypass

Do not hand-roll the escaper — use the library helper, which handles the full character set including the NUL byte. In Node.js, ldapjs lets you build filters as objects so escaping is applied automatically:

const ldap = require('ldapjs');

// SECURE: EqualityFilter escapes the assertion value internally.
// A username of "*)(uid=*" is encoded, not interpreted.
function buildUserFilter(username) {
  return new ldap.filters.AndFilter({
    filters: [
      new ldap.filters.EqualityFilter({ attribute: 'objectClass', value: 'person' }),
      new ldap.filters.EqualityFilter({ attribute: 'uid', value: username }),
    ],
  });
}

client.search('ou=people,dc=example,dc=com', {
  filter: buildUserFilter(req.body.username),
  scope: 'sub',
  attributes: ['dn', 'cn', 'mail'],
}, handleResults);

In Python with ldap3, escape the value explicitly with escape_filter_chars before interpolating it into a filter string:

from ldap3.utils.conv import escape_filter_chars

# SECURE: escape_filter_chars turns * into \2a, ( into \28, and so on.
raw_username = request.form["username"]
safe_username = escape_filter_chars(raw_username)
search_filter = f"(&(objectClass=person)(uid={safe_username}))"

conn.search(
    search_base="ou=people,dc=example,dc=com",
    search_filter=search_filter,
    attributes=["cn", "mail"],
)

The escaper turns *)(uid=* into \2a\29\28uid=\2a, a literal string that the directory searches for as an exact uid value — matching nothing, exactly as intended.


Step 2: Escape Distinguished Name Components Per RFC 4514

Filter escaping is not sufficient when user input becomes part of a distinguished name — for example when building the bind DN uid=jdoe,ou=people,dc=example,dc=com. DNs follow a different grammar (RFC 4514) with a different reserved set: the comma, plus sign, double quote, backslash, angle brackets, semicolon, equals sign, the leading #, and leading or trailing spaces. Applying RFC 4515 filter escaping to a DN value leaves the DN-specific metacharacters unescaped, so a value containing a comma can inject an extra relative distinguished name and move the bind to a different subtree.

Use the DN-specific escaper. In ldap3 that is escape_rdn:

from ldap3.utils.dn import escape_rdn

# SECURE: escape_rdn handles commas, plus, equals, angle brackets, and
# leading/trailing spaces so the value stays within its RDN.
username = escape_rdn(request.form["username"])
bind_dn = f"uid={username},ou=people,dc=example,dc=com"

conn = Connection(server, user=bind_dn, password=request.form["password"])
if not conn.bind():
    abort(401)

Better still, avoid constructing a bind DN from raw input at all. The safer pattern is search-then-bind: bind first with a read-only service account, search for the user by an escaped filter to retrieve their actual DN from the directory, then perform a second bind using that trusted DN and the supplied password. The user-controlled string only ever appears as an escaped filter value, never as DN syntax:

# 1. Bind as the read-only service account (from a secret store).
svc = Connection(server, user=SERVICE_DN, password=SERVICE_PASSWORD, auto_bind=True)

# 2. Look up the user's real DN using an escaped filter (Step 1).
svc.search("ou=people,dc=example,dc=com",
           f"(uid={escape_filter_chars(request.form['username'])})",
           attributes=["dn"])
if not svc.entries:
    abort(401)
user_dn = svc.entries[0].entry_dn  # trusted DN from the directory

# 3. Bind as the user with the DN the directory returned, not one we built.
user_conn = Connection(server, user=user_dn, password=request.form["password"])
if not user_conn.bind():
    abort(401)

Step 3: Validate Input and Bind With Least Privilege

Escaping stops injection, but two more controls shrink the blast radius. First, validate identifiers against an allowlist before they reach the directory. A uid or sAMAccountName has a narrow legal character set — letters, digits, and a few punctuation marks — so reject anything outside it and you have removed the metacharacters before escaping is even in play.

// Allowlist identifiers: reject anything that is not a plausible account name.
const UID_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;

function assertValidUid(uid) {
  if (typeof uid !== 'string' || !UID_PATTERN.test(uid)) {
    throw new HttpError(400, 'Invalid account identifier');
  }
  return uid;
}

Allowlisting and escaping are complementary: the allowlist is a coarse first gate that rejects obviously hostile input and logs it, while escaping is the precise control that makes any value that does pass provably inert.

Second, bind with least privilege. The service account your application uses to search the directory should have read access scoped to the relevant subtree and nothing more — no write permission, no access to password attributes it does not need, and certainly not a Domain Admin or cn=admin credential. If an injection or a bug ever does leak query control, a least-privilege bind caps what an attacker can read or modify. Store the service credential in a secret manager, rotate it, and monitor its bind volume for anomalies.

Finally, disable anonymous bind on the directory where possible, and never treat an anonymous or empty-password bind as a successful authentication — some directories return success for an unauthenticated bind, which turns an empty password field into a bypass all by itself.


Verification

Probe the running authentication and search endpoints with the classic bypass payloads and confirm they are refused.

# 1. Wildcard injection in the username must not authenticate or return all users.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"*","password":"anything"}'
# Expected: 401

# 2. Filter-breaking payload must be rejected by the allowlist or return no match.
curl -s -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"*)(uid=*","password":"x"}'
# Expected: 400 (allowlist) or 401 (escaped, no match) — never 200 with a session

# 3. Confirm the app binds with a non-privileged account.
ldapwhoami -x -H ldap://localhost -D "$SERVICE_DN" -w "$SERVICE_PASSWORD"
# Expected: the service account DN, which should hold read-only rights only

A correctly hardened service returns 400 or 401 for every bypass payload and never surfaces more than the single authorized entry. If payload 1 or 2 yields a session or a full user list, the filter is being concatenated without escaping.


Troubleshooting

Symptom Likely cause Fix
Login with username * succeeds Filter built by concatenation with no escaping; app treats any match as success Escape assertion values with the library helper and require an exact single match plus a real bind
DN-based bind moves to an unexpected subtree Filter escaping applied where DN escaping was needed Use escape_rdn for DN components, or switch to search-then-bind so input never enters DN syntax
Empty password authenticates the user Directory permits unauthenticated (anonymous) bind and app reads it as success Reject empty passwords before binding; disable anonymous bind on the directory
escape_filter_chars leaves parentheses in the string Escaper applied after the filter string was already assembled Escape the value first, then interpolate the escaped value into the filter
Injection works despite escaping Application binds as an administrator, widening what a leak exposes Switch to a read-scoped service account and scope its rights to the needed subtree

Common Implementation Mistakes


Frequently Asked Questions

Why is filter escaping different from DN escaping?

Because they are two different grammars defined by two different RFCs. Search-filter assertion values follow RFC 4515, whose reserved characters are the asterisk, open and close parentheses, backslash, and NUL byte, each escaped as a backslash-hex sequence. Distinguished names follow RFC 4514, which reserves a different set — comma, plus, equals, angle brackets, semicolon, the leading hash, and leading or trailing spaces. Using the filter escaper on a DN value leaves the DN metacharacters live, so you must pick the escaper that matches the context the value is used in.

How does an LDAP auth-bypass filter actually work?

Consider a login that builds (&(uid=INPUT)(userPassword=INPUT)) by concatenation. An attacker sends a username of *)(uid=*, which closes the uid clause early and injects a wildcard, producing a filter that matches every entry in the subtree. If the application interprets any successful search as a valid login, it authenticates the attacker as the first matching account without a correct password. Escaping the asterisk and the parentheses turns the payload into a harmless literal that matches nothing.

Should I rely on my LDAP library to escape for me?

Yes — use the library helper rather than writing your own, because getting the full character set right (including the NUL byte and DN space rules) is easy to botch. ldapjs gives you filter objects that escape automatically, and ldap3 provides escape_filter_chars for filters and escape_rdn for DN components. The pitfall is not the helper itself but where you apply it: build the filter by string concatenation and forget to call it, or use the filter escaper on a DN value, and the injection path is still open.