Browse docs

Authentication with ldapjs

The full login pattern — bind as a service account, find the user, then bind as them to verify their password.

On this page

"Log in with your directory credentials" almost always follows the same three-step pattern, combining Binding and Searching.

The pattern
  1. Bind as a service account that's allowed to search the directory.
  2. Search for the user's entry by username, to get their DN.
  3. Bind again, on a fresh connection, as that DN with the password the user submitted. If that bind succeeds, the password was correct.
ts
import ldap from "ldapjs";

async function authenticate(username: string, password: string): Promise<boolean> {
  const serviceClient = ldap.createClient({ url: "ldaps://dc1.example.com:636" });

  await new Promise<void>((resolve, reject) => {
    serviceClient.bind(
      "CN=svc-app,OU=Service Accounts,DC=example,DC=com",
      process.env.LDAP_BIND_PASSWORD!,
      (err) => (err ? reject(err) : resolve()),
    );
  });

  const userDn = await new Promise<string | null>((resolve, reject) => {
    const opts: ldap.SearchOptions = {
      filter: `(sAMAccountName=${escapeFilterValue(username)})`,
      scope: "sub",
      attributes: ["dn"],
    };

    serviceClient.search("DC=example,DC=com", opts, (err, res) => {
      if (err) return reject(err);
      let dn: string | null = null;
      res.on("searchEntry", (entry) => (dn = entry.pojo.objectName));
      res.on("error", reject);
      res.on("end", () => resolve(dn));
    });
  });

  serviceClient.unbind();

  if (!userDn) return false; // no matching user

  const userClient = ldap.createClient({ url: "ldaps://dc1.example.com:636" });
  const success = await new Promise<boolean>((resolve) => {
    userClient.bind(userDn!, password, (err) => resolve(!err));
  });
  userClient.unbind();

  return success;
}

Security

Always escape the username before interpolating it into a filter — escapeFilterValue here refers to the same escaping described in LDAP filters and LDAP injection. An unescaped username lets an attacker manipulate the search filter.

Why a fresh connection for the user bind

Binding as the user on the same connection you used for the service account would change that connection's identity going forward — any subsequent operation on it would run as the user, not the service account. Using a separate, short-lived client for the verification bind avoids that entirely, and it's cheap to open and close.

Don't skip the "user not found" case

If the search returns no entry, don't attempt the second bind at all — binding with an empty or malformed DN can behave unpredictably across servers. Treat "no matching user" as a failed authentication and return early, exactly as the example above does.

What's next

Once a user is authenticated, you typically need their group membership to authorize what they can do: Groups with ldapjs.