Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

LDAP & Active Directory Integration

Authenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.

Intermediate6 min readUpdated
On this page

Enterprise customers frequently want their existing directory to be the source of truth for users. LDAP is how you talk to it, and Active Directory is the most common implementation you will meet.

Key Takeaways

  • Bind authentication lets the directory verify the password; you never see the hash.
  • Always use LDAPS or StartTLS — bind sends the password to the server.
  • Active Directory has its own provider that searches by sAMAccountName.
  • Map directory groups to application roles explicitly, not by using group names as authorities.
  • Pool connections; a bind per request is slow and stresses the directory.

Structure

An LDAP directory is a tree. Every entry has a distinguished name that is its full path:

dc=example,dc=com                       # domain component — the root
├── ou=users                            # organisational unit
│   ├── uid=alice,ou=users,dc=example,dc=com
│   └── uid=bob,ou=users,dc=example,dc=com
└── ou=groups
    ├── cn=developers,ou=groups,dc=example,dc=com
    └── cn=administrators,ou=groups,dc=example,dc=com

Common attributes: uid or sAMAccountName (login name), cn (common name), sn (surname), mail, memberOf (groups the user belongs to), and member on the group side.

Those last two describe one relationship from opposite ends, and which is available to you is a property of the directory rather than a choice. memberOf is maintained automatically by Active Directory and is cheap to read; OpenLDAP only populates it when the memberof overlay is enabled. member on the group always exists, but finding a user's groups through it means searching the group tree. Check which your directory offers before writing the populator — they lead to different configuration.

Nested groups are the other surprise. A user in Developers, where Developers is itself a member of Engineering, will not appear in a naive (member={0}) search for Engineering. Active Directory can walk the chain with the matching rule (member:1.2.840.113556.1.4.1941:={0}), at a real cost in query time on a large directory. OpenLDAP has no equivalent, so the practical answer there is to flatten the groups you actually care about into the mapping table.

How bind authentication works

The directory verifies the password by attempting a bind. The application never reads or compares a hash.

The two-step shape — search for the DN, then bind as it — exists because the login name a user types is rarely their full distinguished name.

Standard LDAP

LdapConfig.java
@Bean
AuthenticationManager ldapAuthenticationManager(BaseLdapPathContextSource contextSource) {
 
    var factory = new LdapBindAuthenticationManagerFactory(contextSource);
    // {0} is replaced with the submitted username.
    factory.setUserSearchFilter("(uid={0})");
    factory.setUserSearchBase("ou=users");
 
    factory.setLdapAuthoritiesPopulator(authoritiesPopulator(contextSource));
    return factory.createAuthenticationManager();
}
 
@Bean
LdapAuthoritiesPopulator authoritiesPopulator(BaseLdapPathContextSource contextSource) {
    var populator = new DefaultLdapAuthoritiesPopulator(contextSource, "ou=groups");
    // {0} is the user's full DN, {1} the bare username — which one to use
    // depends on whether the directory stores members as DNs or names.
    populator.setGroupSearchFilter("(member={0})");
    populator.setGroupRoleAttribute("cn");
    populator.setRolePrefix("ROLE_");
    populator.setConvertToUpperCase(true);
    populator.setSearchSubtree(true);
    return populator;
}
 
@Bean
LdapContextSource contextSource(LdapProperties props) {
    var source = new LdapContextSource();
    // LDAPS. Plain ldap:// transmits the password in the clear.
    source.setUrl("ldaps://directory.example.com:636");
    source.setBase("dc=example,dc=com");
    source.setUserDn(props.serviceAccountDn());
    source.setPassword(props.serviceAccountPassword());
    source.setPooled(true);
    return source;
}

setPooled(true) deserves one caveat. The pool is shared, and a bind authentication changes the identity of the connection it runs on, so Spring LDAP deliberately keeps authentication binds out of it. What pooling saves is the service-account searches, which are the bulk of the traffic — worth having, but it will not eliminate connection setup on login.

Active Directory

Active Directory deviates from standard LDAP enough that Spring provides a dedicated provider:

ActiveDirectoryConfig.java
@Bean
AuthenticationProvider activeDirectoryProvider() {
    var provider = new ActiveDirectoryLdapAuthenticationProvider(
            "corp.example.com",                  // domain
            "ldaps://dc1.corp.example.com:636");
 
    // AD returns detailed error codes — expired password, account locked,
    // must change password. Surfacing them gives users an actionable message
    // instead of a generic failure.
    provider.setConvertSubErrorCodesToExceptions(true);
 
    // Restrict which users may authenticate at all.
    provider.setSearchFilter(
        "(&(objectClass=user)(userPrincipalName={0})(memberOf=CN=AppUsers,OU=Groups,DC=corp,DC=example,DC=com))");
 
    provider.setUseAuthenticationRequestCredentials(true);
    return provider;
}

The AD provider binds as user@domain directly, so it needs no service account for the initial search — a meaningful operational simplification, since service account credentials in a directory are a sensitive thing to hold.

Note the sub-error codes. Without them, "your password expired" and "wrong password" are the same generic failure, and support tickets follow.

userPrincipalName and sAMAccountName are not interchangeable, and confusing them is the usual cause of "it works for most people". The first looks like an email address and is unique across the forest; the second is the short pre-Windows-2000 name, unique only within a single domain. This provider appends @domain to a bare username, so someone signing in as alice binds as alice@corp.example.com — correct only if that matches their UPN, which in merged organisations it often does not.

Mapping groups to roles

GroupMapping.java
@Component
public class DirectoryGroupMapper {
 
    // Directory groups are named for the organisation and get renamed by
    // people who have never heard of your application. An explicit table
    // means a rename is a config change, not an outage.
    private static final Map<String, String> GROUP_TO_ROLE = Map.of(
            "CN=AppAdmins,OU=Groups,DC=corp,DC=example,DC=com", "ADMIN",
            "CN=AppUsers,OU=Groups,DC=corp,DC=example,DC=com", "USER",
            "CN=Finance,OU=Groups,DC=corp,DC=example,DC=com", "FINANCE");
 
    public Collection<GrantedAuthority> map(Collection<String> groupDns) {
        return groupDns.stream()
                .map(GROUP_TO_ROLE::get)
                .filter(Objects::nonNull)
                .distinct()
                .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
                .toList();
    }
}

Using group names directly as authorities couples your authorisation rules to someone else's naming decisions. An explicit mapping also documents which directory groups grant which access, which is exactly what an access review asks for.

Local enrichment

Directory authentication rarely means the directory holds everything. Provision a local record on first login for the application-specific data:

LdapUserDetailsMapper.java
@Component
public class ProvisioningUserDetailsMapper implements UserDetailsContextMapper {
 
    private final UserRepository users;
 
    @Override
    public UserDetails mapUserFromContext(DirContextOperations ctx, String username,
                                          Collection<? extends GrantedAuthority> authorities) {
        String email = ctx.getStringAttribute("mail");
        String displayName = ctx.getStringAttribute("displayName");
 
        // The directory owns identity; we own preferences, audit and
        // application state.
        User local = users.findByDirectoryUid(username)
                .orElseGet(() -> users.save(User.provisionFromDirectory(username, email, displayName)));
        local.recordLogin(Instant.now());
 
        return new AcmeUserDetails(local, authorities);
    }
}

Testing

LdapAuthenticationTest.java
@SpringBootTest
@AutoConfigureTestDatabase
class LdapAuthenticationTest {
 
    // Embedded UnboundID: no external directory needed in CI.
    @Autowired AuthenticationManager authenticationManager;
 
    @Test
    void authenticatesAndMapsGroups() {
        var auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken("alice", "password"));
 
        assertThat(auth.isAuthenticated()).isTrue();
        assertThat(auth.getAuthorities())
                .extracting(GrantedAuthority::getAuthority)
                .contains("ROLE_DEVELOPERS");
    }
}
application-test.yml
spring:
  ldap:
    embedded:
      base-dn: 'dc=example,dc=com'
      ldif: 'classpath:test-directory.ldif'
      port: 8389

An embedded directory seeded from an LDIF file makes LDAP testable in CI with no infrastructure, which is otherwise a genuine obstacle to writing these tests at all.

What to take away

Use bind authentication over LDAPS so the password is verified by the directory and never travels in clear. Use the Active Directory provider for AD, with sub-error codes on so users get actionable messages. Map groups to roles through an explicit table, provision a local record for application data, and pool connections so login is not a new bind every time.

Frequently Asked Questions

Bind authentication or password comparison?
Bind, almost always. It asks the directory to authenticate by attempting a connection as the user, so the password hash never leaves the server and you do not need read access to it. Password comparison requires the application to read the hash, which most directories rightly refuse.
Why does authentication fail only for some users?
Usually the search base or filter. A user in a different organisational unit than the one you search will not be found, and Active Directory forests often have several. Widen the base, or use the Active Directory provider which searches by sAMAccountName across the domain.
Is LDAP over plain TCP ever acceptable?
No. Bind authentication sends the password to the directory, so plaintext LDAP transmits credentials in the clear on every login. Use LDAPS on port 636 or StartTLS on 389, and verify the certificate rather than trusting everything.

Related tutorials