Locking Down Your Quarkus Apps with LDAP Authentication
A hands-on guide to securing REST endpoints in Quarkus using Elytron and Compose Dev Services for LDAP
You’ll build a small Quarkus app with three endpoints, protect two of them with LDAP, and run a local LDAP server in a container using Compose Dev Services. This mirrors the flow of Spring’s guide but uses Quarkus idioms and Elytron LDAP.
Why it matters for developers:
Most organizations already have LDAP. Reusing it for app auth reduces risk and avoids duplicating user stores. And most of the times, this is still the preferred way to integrate authentication and authorization.
Quarkus integrates directly with LDAP through Elytron. You get fast startup, small footprint, and clean role mapping.
We’ll use Bitnami OpenLDAP as the local directory and seed it with users and groups via LDIF. Quarkus’ Compose Dev Services will spin up the container automatically in dev and test modes with Podman or Docker.
Prerequisites
JDK 17+
Maven 3.9+
Podman (or if you have to, Docker), with Compose support
curl
Compose Dev Services works with Docker Compose and Podman Compose. Quarkus auto-detects compose files in your project and starts/stops services for you in dev and tests.
Bootstrap the project
Create a Quarkus app with REST + Elytron LDAP:
quarkus create app org.acme:quarkus-ldap-demo:1.0.0 \
-x rest-jackson,elytron-security-ldap,hibernate-panache-orm,jdbc-postgres
cd quarkus-ldap-demo
This adds the quarkus-elytron-security-ldap
extension, the Quarkus adapter for Elytron’s LDAP realm.
Compose Dev Services: local LDAP in a container
Create compose-devservices.yml
at the project root. Quarkus will discover this and manage lifecycle automatically in dev/tests.
# compose-devservices.yml
services:
openldap:
image: bitnami/openldap:latest
# Expose port to localhost for Quarkus to connect
ports:
- "1389:1389"
healthcheck:
test: ["CMD", "ldapsearch", "-x", "-H", "ldap://localhost:1389", "-b", "dc=quarkus,dc=io"]
interval: 5s
timeout: 3s
retries: 10
environment:
- LDAP_ADMIN_USERNAME=admin
- LDAP_ADMIN_PASSWORD=adminpassword
- LDAP_ROOT=dc=quarkus,dc=io
# Auto-load custom LDIFs from /ldifs at first start
- LDAP_CUSTOM_LDIF_DIR=/ldifs
volumes:
- ./ldifs:/ldifs:Z
Automatic LDAP Provisioning: Quarkus Dev Services uses this Compose file to automatically start an OpenLDAP container. Note: This only happens, if you have one of the standard Dev Services as a dependency (in our case Postgresql)
Port Mapping & Health Checks: Maps container port 1389 to localhost:1389 so Quarkus can connect, and includes health checks to ensure the LDAP server is fully ready before the application starts.
Custom Data Loading: Mounts the local ldifs/ directory into the container and configures it to automatically load your custom LDIF files containing users, groups, and organizational structure during container initialization.
Seed roles with LDIF
Create ldifs/10-groups.ldif
:
version: 1
dn: dc=quarkus,dc=io
dc: quarkus
objectClass: top
objectClass: domain
#The user OU
dn: ou=Users,dc=quarkus,dc=io
objectClass: organizationalUnit
objectClass: top
ou: Users
#The users
dn: uid=noRoleUser,ou=Users,dc=quarkus,dc=io
objectClass: top
objectClass: person
objectClass: inetOrgPerson
cn: No Role User
sn: noRoleUser
uid: noRoleUser
userPassword: noRoleUserPassword
dn: uid=standardUser,ou=Users,dc=quarkus,dc=io
objectClass: top
objectClass: person
objectClass: inetOrgPerson
cn: StandardUser
sn: standardUser
uid: standardUser
userPassword: standardUserPassword
displayName: Standard User
dn: uid=adminUser,ou=Users,dc=quarkus,dc=io
objectClass: top
objectClass: person
objectClass: inetOrgPerson
cn: AdministratorUser
sn: adminUser
uid: adminUser
userPassword: adminUserPassword
# A sub OU of Users
dn: ou=SubUsers,ou=Users,dc=quarkus,dc=io
objectclass: organizationalUnit
objectclass: top
ou: SubUsers
dn: uid=subUser,ou=SubUsers,ou=Users,dc=quarkus,dc=io
objectclass: top
objectclass: person
objectclass: inetOrgPerson
cn: SubUser
sn: subUser
uid: subUser
userpassword: subUserPassword
#The roles OU
dn: ou=Roles,dc=quarkus,dc=io
objectclass: top
objectclass: organizationalUnit
ou: Roles
#The roles
dn: cn=standardRole,ou=Roles,dc=quarkus,dc=io
objectClass: top
objectClass: groupOfNames
cn: standardRole
member: uid=standardUser,ou=Users,dc=quarkus,dc=io
dn: cn=adminRole,ou=Roles,dc=quarkus,dc=io
objectClass: top
objectClass: groupOfNames
cn: adminRole
member: uid=adminUser,ou=Users,dc=quarkus,dc=io
Root Domain: dc=quarkus,dc=io serves as the base domain with two main organizational units - ou=Users for user accounts and ou=Roles for group/role definitions.
User Hierarchy: Users are organized under ou=Users with individual user entries using uid as the identifier, including a nested ou=SubUsers sub-OU to demonstrate hierarchical organization within the user structure.
Role-Based Access Control: Groups/roles are defined in ou=Roles using groupOfNames object class with member attributes that reference specific user DNs, enabling role-based authorization by linking users to their assigned roles.
Implement the secured endpoints
Create src/main/java/org/acme/security/PublicResource.java
:
package org.acme.security;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/public")
public class PublicResource {
@GET
@PermitAll
@Produces(MediaType.TEXT_PLAIN)
public String publicResource() {
return "public";
}
}
/api/public
is open. The next two endpoints are role-protected.
src/main/java/org/acme/security/AdminResource.java
:
package org.acme.security;
import org.jboss.logging.Logger;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/admin")
public class AdminResource {
private static final Logger LOG = Logger.getLogger(AdminResource.class);
@Context
SecurityContext securityContext;
@Inject
SecurityIdentity securityIdentity;
@GET
@RolesAllowed("adminRole")
@Produces(MediaType.TEXT_PLAIN)
public String adminResource() {
if (securityContext.getUserPrincipal() != null) {
LOG.infof("Admin endpoint accessed by user: %s", securityContext.getUserPrincipal().getName());
LOG.infof("User principal class: %s", securityContext.getUserPrincipal().getClass().getName());
LOG.infof("User has adminRole: %s", securityContext.isUserInRole("adminRole"));
LOG.infof("Authentication scheme: %s", securityContext.getAuthenticationScheme());
LOG.infof("Is secure: %s", securityContext.isSecure());
// Access roles and groups from SecurityIdentity
LOG.infof("User roles: %s", securityIdentity.getRoles());
LOG.infof("User attributes: %s", securityIdentity.getAttributes());
// Check specific roles
LOG.infof("Has adminRole: %s", securityIdentity.hasRole("adminRole"));
LOG.infof("Has standardRole: %s", securityIdentity.hasRole("standardRole"));
// Access LDAP-specific attributes if available
securityIdentity.getAttributes().forEach((key, value) -> {
LOG.infof("Attribute %s: %s", key, value);
});
} else {
LOG.warn("Admin endpoint accessed without authenticated user");
}
return "admin";
}
}
src/main/java/org/acme/security/UserResource.java
:
package org.acme.security;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/users")
public class UserResource {
@GET
@Path("/me")
@RolesAllowed("standardRole")
public String me(@Context SecurityContext sc) {
return sc.getUserPrincipal().getName();
}
}
Configure LDAP in application.properties
Create src/main/resources/application.properties
:
# Enable Elytron LDAP
quarkus.security.ldap.enabled=true
# Bind to LDAP using the admin user created by the container
quarkus.security.ldap.dir-context.url=ldap://localhost:1389
quarkus.security.ldap.dir-context.principal=cn=admin,dc=quarkus,dc=io
quarkus.security.ldap.dir-context.password=adminpassword
# Users live under ou=Users (case sensitive)
quarkus.security.ldap.identity-mapping.search-base-dn=ou=Users,dc=quarkus,dc=io
# Users are identified by uid
quarkus.security.ldap.identity-mapping.rdn-identifier=uid
# Map LDAP groups to roles:
# Groups are in ou=Roles with groupOfNames structure
# Map the group's cn to a SecurityIdentity role if member matches the user DN
quarkus.security.ldap.identity-mapping.attribute-mappings."0".from=cn
quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter=(member=uid={0},ou=Users,dc=quarkus,dc=io)
quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter-base-dn=ou=Roles,dc=quarkus,dc=io
LDAP Connection Setup: Configures Quarkus to connect to the OpenLDAP server at localhost:1389 using admin credentials (cn=admin,dc=quarkus,dc=io) and enables Elytron LDAP security for authentication and authorization.
User Identity Mapping: Defines how Quarkus locates and identifies users by searching in ou=Users,dc=quarkus,dc=io and using the uid attribute as the unique identifier for each user account.
Role-Based Authorization: Maps LDAP groups to Quarkus security roles by searching for groupOfNames entries in ou=Roles,dc=quarkus,dc=io and checking if the authenticated user's DN matches any group's member attribute, converting the group's cn (common name) into a Quarkus security role.
Build and run
Start dev mode. Quarkus will detect compose-devservices.yml
, start OpenLDAP, and wire config:
./mvnw quarkus:dev
Verify:
curl -i http://localhost:8080/api/public
# HTTP/1.1 200 OK
# public
curl -i http://localhost:8080/api/admin
# HTTP/1.1 401 Unauthorized
# adminRole is granted to adminUser via LDIF
curl -i -u adminUser:adminUserPassword http://localhost:8080/api/admin
# HTTP/1.1 200 OK
# admin
# standardRole is granted to standardUser via LDIF
curl -i -u standardUser:standardUserPassword http://localhost:8080/api/users/me
# HTTP/1.1 200 OK
# standardUser
Make sure to pay attention to the logs when you use the admin endpoint:
[org.acm.sec.AdminResource] Admin endpoint accessed by user: adminUser
[org.acm.sec.AdminResource] User principal class: org.wildfly.security.auth.principal.NamePrincipal
[org.acm.sec.AdminResource] User has adminRole: true
[org.acm.sec.AdminResource] Authentication scheme: Basic
[org.acm.sec.AdminResource] Is secure: false
[org.acm.sec.AdminResource] User roles: [adminRole]
[org.acm.sec.AdminResource] User attributes: {groups=[adminRole]}
[org.acm.sec.AdminResource] Has adminRole: true
[org.acm.sec.AdminResource] Has standardRole: false
[org.acm.sec.AdminResource] Attribute groups: [adminRole]
Basic Authentication Info: Logs the authenticated user's name, principal class type, authentication scheme (e.g., BASIC), and connection security status to verify successful LDAP authentication.
Role and Permission Details: Logs all assigned roles from the SecurityIdentity, checks specific role membership (adminRole, standardRole), and verifies role-based access control is working correctly.
Complete LDAP Attribute Mapping: Iterates through and logs all LDAP attributes that were mapped from the directory entry (uid, cn, sn, etc.) to show what user data is available from the LDAP integration.
AND please! Never do this in production. This is a demo. A safe place. Where we learn together. We can log ALL THE THINGS here. But not in production!
Tests with RestAssured
Add an integration test src/test/java/org/acme/security/SecurityLdapTest.java
:
package org.acme.security;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
@QuarkusTest
class SecurityLdapTest {
@Test
void public_is_open() {
RestAssured.given()
.get("/api/public")
.then()
.statusCode(200)
.body(equalTo("public"));
}
@Test
void admin_requires_role() {
RestAssured.given()
.auth().basic("adminUser", "adminUserPassword")
.get("/api/admin")
.then()
.statusCode(200)
.body(equalTo("admin"));
}
@Test
void user_me_requires_standardRole() {
RestAssured.given()
.auth().basic("standardUser", "standardUserPassword")
.get("/api/users/me")
.then()
.statusCode(200)
.body(equalTo("standardUser"));
}
}
When you run tests, Compose Dev Services starts a separate set of services by default, keeping isolation from dev mode. You can opt to reuse the same project if desired.
Run:
./mvnw test
Production Notes for Quarkus LDAP Demo
Directory Schema Differences. Our project uses groupOfNames/member structure. The attribute mapping is configured as (member=uid={0},ou=Users,dc=quarkus,dc=io) to match our LDAP tree where groups contain full user DNs as members. For other directory types, adjust the filter pattern and base DN accordingly.
TLS. Replace ldap://localhost:1389 with ldaps://your-ldap-server:636 and configure proper trust settings.
Caching. Enable LDAP caching by uncommenting and configuring the cache properties in application.properties: quarkus.security.ldap.cache.enabled=true, quarkus.security.ldap.cache.max-age=60s, and quarkus.security.ldap.cache.size=100 to reduce LDAP round trips while maintaining reasonable data freshness.
Seeding Data. Our setup uses Bitnami OpenLDAP's automatic LDIF import feature via LDAP_CUSTOM_LDIF_DIR=/ldifs and volume mount ./ldifs:/ldifs:Z. The 10-groups.ldif file is automatically loaded on first container startup, providing reproducible user/group data for demos and tests without manual LDAP configuration.
When you bring LDAP into your Quarkus project with Dev Services, you’re not just wiring up authentication, you’re creating a setup that mirrors enterprise reality while staying lightweight for local development. Developers can test role mappings, seed realistic directories, and run automated security tests without manual infrastructure. This closes the gap between proof of concept and production, giving teams the confidence that what works locally will stand in real environments.
Secure by default. Fast to prove. Easy to evolve.