Active Directory is deployed in the majority of enterprise environments and will remain so for decades. Despite SAML and OIDC being the modern standard for application SSO, many enterprises — particularly in manufacturing, government, and education — still require direct LDAP authentication for applications that cannot support a full SAML/OIDC integration. Understanding the LDAP bind authentication pattern, AD-specific attributes, and the operational gotchas is essential for serving enterprise customers.
LDAP bind authentication
LDAP authentication works via a "bind" operation. The client connects to the LDAP server, sends a distinguished name (DN) and password, and the server returns a success or failure. A successful bind proves that the user knows the password for that DN.
The complication is that users typically provide their username or email, not their full DN. The standard approach is a two-step process: first, bind with a service account to search for the user's DN, then bind with the user's DN and the provided password to verify credentials.
// LDAP authentication with Node.js (ldapts library)
import { Client } from 'ldapts';
interface LdapConfig {
url: string; // ldap:// or ldaps://
baseDn: string; // DC=example,DC=com
bindDn: string; // service account: CN=ServiceAccount,DC=example,DC=com
bindPassword: string;
userSearchFilter: string; // (&(objectClass=user)(sAMAccountName={{username}}))
userAttributes: string[]; // ['cn', 'mail', 'memberOf', 'department']
}
async function authenticateWithLdap(
config: LdapConfig,
username: string,
password: string
): Promise<LdapUser | null> {
const client = new Client({ url: config.url, tlsOptions: { rejectUnauthorized: true } });
try {
// Step 1: bind as service account to search for the user
await client.bind(config.bindDn, config.bindPassword);
const searchFilter = config.userSearchFilter.replace('{{username}}', ldapEscape(username));
const { searchEntries } = await client.search(config.baseDn, {
scope: 'sub',
filter: searchFilter,
attributes: [...config.userAttributes, 'distinguishedName']
});
if (searchEntries.length === 0) {
return null; // User not found in directory
}
const userEntry = searchEntries[0];
const userDn = userEntry.dn;
// Unbind the service account before binding as the user
await client.unbind();
// Step 2: bind as the user to verify their password
await client.bind(userDn, password);
// Authentication successful — extract user attributes
return {
dn: userDn,
username: String(userEntry.sAMAccountName || userEntry.uid || username),
email: String(userEntry.mail || ''),
displayName: String(userEntry.cn || userEntry.displayName || ''),
department: String(userEntry.department || ''),
groups: parseGroupMembership(userEntry.memberOf)
};
} catch (err: any) {
if (err.code === 49) {
// LDAP error 49 = InvalidCredentials
return null;
}
throw err;
} finally {
await client.unbind().catch(() => {});
}
}
// Escape special LDAP search filter characters
function ldapEscape(value: string): string {
return value.replace(/[\\*()\\x00]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
}
\ * ( ) \0.Active Directory-specific attributes
Active Directory has its own attribute names that differ from generic LDAP schemas:
sAMAccountName: the pre-Windows 2000 logon name (typically what users type as their username)userPrincipalName: the UPN, typically in the formatuser@domain.comdistinguishedName: the full DN, likeCN=Alice Smith,OU=Engineering,DC=example,DC=comobjectGUID: a globally unique identifier that persists even if the account is renamed or movedmemberOf: a multi-value attribute containing the DNs of all groups the user belongs touserAccountControl: a bitmask indicating account state (disabled, locked out, password expired)
// Checking account status from userAccountControl bitmask
const UAC_FLAGS = {
ACCOUNTDISABLE: 0x0002,
LOCKOUT: 0x0010,
PASSWD_EXPIRED: 0x800000,
SMARTCARD_REQUIRED: 0x40000
};
function isAccountActive(userAccountControl: number): {
active: boolean;
reason?: string;
} {
if (userAccountControl & UAC_FLAGS.ACCOUNTDISABLE) {
return { active: false, reason: 'account_disabled' };
}
if (userAccountControl & UAC_FLAGS.LOCKOUT) {
return { active: false, reason: 'account_locked' };
}
if (userAccountControl & UAC_FLAGS.PASSWD_EXPIRED) {
return { active: false, reason: 'password_expired' };
}
return { active: true };
}
// Group membership from memberOf attribute
function parseGroupMembership(memberOf: string | string[] | undefined): string[] {
if (!memberOf) return [];
const raw = Array.isArray(memberOf) ? memberOf : [memberOf];
return raw.map(dn => {
// Extract CN from DN: "CN=Engineering,OU=Groups,DC=example,DC=com" → "Engineering"
const match = dn.match(/^CN=([^,]+)/i);
return match ? match[1] : dn;
});
}
LDAPS and secure connections
Plain LDAP (port 389) sends credentials in the clear over the network. In enterprise environments where your application server is not on the same network segment as the domain controller, this is a serious risk. Use LDAPS (LDAP over TLS, port 636) or LDAP with STARTTLS. LDAPS is simpler to configure correctly and does not have the STARTTLS downgrade attack surface.
The AD domain controller's certificate is typically signed by the enterprise's internal CA. You need to trust that CA in your application's certificate bundle. This is the most common LDAPS integration failure: developers test against a server with a self-signed cert, disable TLS verification, and then that setting makes it to production.
// LDAPS configuration with custom CA bundle
import { readFileSync } from 'fs';
const client = new Client({
url: 'ldaps://dc.example.com:636',
tlsOptions: {
rejectUnauthorized: true, // NEVER set false in production
ca: readFileSync('/etc/ssl/certs/corporate-ca.pem') // enterprise CA bundle
},
connectTimeout: 10000,
timeout: 30000
});
Group-to-role mapping
Enterprise customers map AD groups to application roles. The mapping configuration should allow administrators to specify which AD group (by name or DN) maps to which application role. Multiple groups can map to the same role, and a user who is in none of the mapped groups should typically be denied access or receive a default "no access" role.
// Group mapping configuration
const groupMappings: Record<string, string> = {
'AppAdmins': 'admin',
'AppEditors': 'editor',
'AppViewers': 'viewer',
'AppUsers': 'viewer' // multiple groups can map to same role
};
function mapGroupsToRole(groups: string[], mappings: Record<string, string>): string | null {
// Priority: first role found wins, ordered by privilege
const rolePriority = ['admin', 'editor', 'viewer'];
const userRoles = groups
.map(g => mappings[g])
.filter(Boolean);
for (const role of rolePriority) {
if (userRoles.includes(role)) return role;
}
return null; // user has no mapped role — deny access
}
Common LDAP integration pitfalls: searching the wrong base DN (searching DC=example,DC=com when users are in OU=Corporate,DC=example,DC=com), not handling referrals (AD sends LDAP referrals when the user is in a different domain in a forest), and not handling reconnection when the LDAP connection drops. Keep connection management robust — LDAP connections are long-lived and the server may terminate idle connections after a timeout.