LDAP & Active Directory Integration
Authenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.
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=comCommon 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 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
@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:
@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
@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:
@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
@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");
}
}spring:
ldap:
embedded:
base-dn: 'dc=example,dc=com'
ldif: 'classpath:test-directory.ldif'
port: 8389An 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?
Why does authentication fail only for some users?
Is LDAP over plain TCP ever acceptable?
Related tutorials
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.
- Role-Based Access Control (RBAC)Authorisation with roles: HTTP versus method security, role hierarchies, @PreAuthorize and @PostAuthorize, custom PermissionEvaluator, and where RBAC stops being enough.
- Social Login — Google, GitHub, MicrosoftAdding sign in with Google, GitHub and Microsoft: client registration, mapping provider profiles to your user model, safe account linking, and the onboarding flow afterwards.
- Attribute-Based Access Control (ABAC)When roles are not enough: the PDP/PEP model, Open Policy Agent and Rego, integrating OPA with Spring, and deciding between ABAC and a richer RBAC.