"Log in with your directory credentials" almost always follows the same three-step pattern, combining Binding and Searching.
- Bind as a service account that's allowed to search the directory.
- Search for the user's entry by username, to get their DN.
- Bind again, on a fresh connection, as that DN with the password the user submitted. If that bind succeeds, the password was correct.
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.
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.
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.
Once a user is authenticated, you typically need their group membership to authorize what they can do: Groups with ldapjs.