LDAP and Active Directory authentication: the enterprise integration that never dies

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')}`);
}
Always escape user-supplied values before inserting them into LDAP search filters. LDAP injection is the LDAP equivalent of SQL injection — an attacker who can inject into the filter can potentially bypass authentication or exfiltrate directory data. The characters to escape are: \ * ( ) \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 format user@domain.com
  • distinguishedName: the full DN, like CN=Alice Smith,OU=Engineering,DC=example,DC=com
  • objectGUID: a globally unique identifier that persists even if the account is renamed or moved
  • memberOf: a multi-value attribute containing the DNs of all groups the user belongs to
  • userAccountControl: 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.

← Back to blog Try Bastionary free →