Configuring LDAP
This page shows how to set up the embedded LDAP v3 directory and point an
LDAP client such as ldapsearch or ldapmodify at it. Each recipe covers
the people, groups and settings to create, and what the client then sends.
It is given twice: once as steps in the admin console and once as
Management API calls.
The values come from this repository’s test jobs, and each recipe names the
job it is taken from. Most are tests/protocol/ jobs, which run against a
service over its own sockets in both development and product mode. A few are
in-process tests in tests/, and the recipe says so. Change the names,
passwords and domains to your own.
How the directory behaves, and every setting with its default, is on
LDAP. This page covers only what to fill in.
Configuring OAuth 2.0 grants → Before you start
explains the shared mechanics: the API token and the api shell helper used
below. The examples here also use plain curl for the GET calls.
- Configuring LDAP
- Where things are
- Trust the LDAPS certificate
- Put a person in the directory and bind as them
- Turn off the plain listener
- Write the directory over LDAP
- Let people change their own entry
- Publish an address book
- A trust realm’s own tree
- Groups
- The password policy
- People who hold a second factor: app passwords
- Ask for, or require, a client certificate on 636
- Large searches
- What the directory does not offer
- Related
Where things are
| Page | Console menu | What you do there |
|---|---|---|
/admin/ldap |
Protocols → LDAP / LDAPS | The ldap.* settings: ports, the plain listener, the search limits, and who may read and write what in product mode. Saved with Save LDAP. |
/admin/ldap/service |
Directory → As the directory holds it → The directory service | Whether 389 and 636 actually bound, the base DN, the certificate 636 presents, and the bind policy. |
/admin/ldap/directory |
Directory → As the directory holds it → Every entry | Every entry, DN by DN, with every attribute. |
/admin/users |
Directory → Users | Create a person (Create a user → Create ›), and on a person’s page set a password or make an app password. |
/admin/groups |
Directory → Groups | Create a group and add members. |
/admin/policies |
Directory → Policies | The password policy and the attribute policy. |
/admin/rbac |
Server configuration → Admin roles | Give somebody Admin Read or Admin Write, which is what lets them read or write the whole directory over LDAP in product mode. |
/admin/listeners |
Server configuration → Listeners | The Listener: LDAPS group: whether 636 asks for or requires a client certificate, and its TLS versions and ciphers. |
The live state of both sockets is GET /admin-api/ldap/service. Read it
first when a client cannot connect. It is the only place that says whether a
listener is up, because the page answers 200 either way.
What development and product mode change. In development mode every bind
succeeds (any DN, any password, anonymous, on 389 and 636), except the
password invalid and a disabled account. Any connection may read and write
anything. Product mode does none of this:
- A bind is verified against the entry’s
userPassword, and only an entry underou=usersmay bind. - An anonymous bind is refused (48), and so is any bind on 389 (13,
confidentialityRequired). Use LDAPS on 636. - A read needs a bind (50). The root DSE is the one exception.
- A person reads their own entry and nothing of anybody else’s, unless
ldap.directoryReadableAttributessays otherwise. - Only Admin Write writes. A person may change only the attributes in
ldap.selfWritableAttributes, on their own entry. - A secret attribute (
userPassword, client secrets, private keys) is never returned, even to an administrator. - A new person must carry what the realm’s attribute policy requires:
uid,cnandsnalways, and by defaultgivenNameandmailtoo. - Nothing is seeded but the containers. There is no
cn=admin, alice, bob or carol.
So in product mode, create the person you will bind as before you connect. Every recipe below does.
Trust the LDAPS certificate
Port 636 presents the same certificate as the main HTTPS port.
GET /tls/server-certificate returns it with its chain and this service’s
Root. Give that file to the client as its CA. sts_ldaps.js checks that the
handshake verifies with it and that the two ports present one certificate.
curl -sk "$BASE/tls/server-certificate" > sts.pem
export LDAPTLS_CACERT=$PWD/sts.pem
The certificate’s key is kept across a restart only where minted state
persists. In development, fetch the file again after a restart rather than
setting LDAPTLS_REQCERT=never.
Then read the root DSE. It answers an unbound connection in every mode, and
its namingContexts lists every realm’s base:
ldapsearch -H ldaps://localhost:636 -x -b "" -s base namingContexts
Use ldaps://, not -Z or -ZZ. The directory has no StartTLS.
Put a person in the directory and bind as them
The person is created through the management API with a real password, as
product mode requires of anybody who binds. Then they bind over LDAPS and
read their own entry. Taken from sts_ldaps.js; the job adds a run stamp to
the name and a random part to the password.
Console:
- On Directory → Users, under Create a user, click Create ›.
- Under Who they are, set Username to
ldaps-run1. - Under How they get in, choose A password I type, and type the password in Password and Again.
- Under What is known about them, fill in Common name (
cn)LDAPS ldaps-run1, Given nameLDAPS, Surnameldaps-run1, Display nameLDAPS ldaps-run1and Email addressldaps-run1@ldaps.test. - Click Create the user.
API:
api users/create '{"username":"ldaps-run1","invent":false,
"credential":"password","password":"Ldaps-Example-Passw0rd-Aa1!",
"attributes":{"cn":"LDAPS ldaps-run1","givenName":"LDAPS","sn":"ldaps-run1",
"displayName":"LDAPS ldaps-run1","mail":"ldaps-run1@ldaps.test"}}'
The answer’s dn is the entry, uid=ldaps-run1,ou=users,dc=example,dc=com
in the default realm. invent: false writes only what you sent. Leave it out
in development to have the rest of a persona made up.
The client:
ldapsearch -H ldaps://localhost:636 -x \
-D "uid=ldaps-run1,ou=users,dc=example,dc=com" -w 'Ldaps-Example-Passw0rd-Aa1!' \
-b "uid=ldaps-run1,ou=users,dc=example,dc=com" -s base '(objectClass=*)'
What the job expects in each mode:
| The client does | Product | Development |
|---|---|---|
| bind with the right password on 636 | accepted | accepted |
| bind with a wrong password | 49 invalidCredentials |
accepted |
| bind anonymously | 48 inappropriateAuthentication |
accepted |
| search without binding | 50 insufficientAccessRights |
answered |
| read the root DSE without binding | answered | answered |
| bind with the right password on 389 | 13 confidentialityRequired |
accepted |
In development mode you can also bind as the seeded cn=admin,dc=example,dc=com
with any password but invalid. The examples on LDAP use
password!.
The entry’s entryUUID is the person’s subject. It comes back only when
asked for by name. A token’s sub for this person is urn:uuid:<entryUUID>,
and a SCIM id is the bare value.
ldapsearch ... -b "uid=ldaps-run1,ou=users,dc=example,dc=com" -s base '(objectClass=*)' entryUUID
Turn off the plain listener
A verified bind on 389 would send a real password in the clear, so product
mode refuses every bind there, answers only the root DSE, and logs a warning
at startup while 389 is on. Turn it off. ldap.plainListener takes effect at
the next restart.
Console: on Protocols → LDAP / LDAPS, set Plain LDAP listener to off, click Save LDAP, and restart the service.
API:
api config/set '{"key":"ldap.plainListener","value":false}'
Or set LDAP_PLAIN_LISTENER=false in the container’s environment. The ports
themselves are ldap.port (LDAP_PORT, 389) and ldap.tlsPort
(LDAPS_PORT, 636), also read at startup. A host run that is not root cannot
bind either; /admin/ldap/service then says so.
Write the directory over LDAP
In product mode only somebody holding Admin Write may add, delete or
rename an entry, or change somebody else’s. This recipe gives that role to a
person, then adds people and a group over LDAP and adds a member. Taken from
sts_directory_bulk_load_ldap.js, which binds as a person it created and,
in product mode, grants Admin Write first. In development mode any
connection may write, and the grant is not needed.
Grant Admin Write. Create the person first, as in the recipe above.
Console: on Server configuration → Admin roles, under Grant a role, use Find a person to pick them, choose Admin Write as the Role, and click Grant.
API:
api rbac/grant '{"username":"bulk-ldap-binder","role":"write"}'
rbac/revoke with the same body takes it back. A grant made in the default
realm covers every realm. A grant made at /realm/<id>/admin-api covers that
realm only.
Add a person. The job writes the same object classes and uid as the
console’s create. In product mode the entry must carry the attribute
policy’s required attributes, which by default are cn, sn, givenName
and mail:
ldapadd -H ldaps://localhost:636 -x \
-D "uid=bulk-ldap-binder,ou=users,dc=example,dc=com" -w "$BIND_PASSWORD" <<'EOF'
dn: uid=dave,ou=users,dc=example,dc=com
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: dave
cn: Dave Davis
sn: Davis
givenName: Dave
mail: dave@example.com
EOF
Add a group, then a member. The group is a groupOfNames with no
member at first, which this schemaless directory accepts. A member is a DN:
ldapadd -H ldaps://localhost:636 -x -D "$BIND_DN" -w "$BIND_PASSWORD" <<'EOF'
dn: cn=engineering,ou=groups,dc=example,dc=com
objectClass: top
objectClass: groupOfNames
cn: engineering
description: Created over LDAP.
EOF
ldapmodify -H ldaps://localhost:636 -x -D "$BIND_DN" -w "$BIND_PASSWORD" <<'EOF'
dn: cn=engineering,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: uid=dave,ou=users,dc=example,dc=com
EOF
The refusals a client may meet on these writes:
| Result | Why |
|---|---|
32 noSuchObject |
the parent container does not exist |
50 insufficientAccessRights |
product mode, and the bound identity does not hold Admin Write |
68 entryAlreadyExists |
another entry already answers to that username, under whatever naming attribute |
19 constraintViolation |
product mode, and a userPassword breaks the password policy |
65 objectClassViolation |
product mode, and the new person lacks an attribute the realm’s attribute policy requires |
11 adminLimitExceeded |
the directory is full (ldap.maxEntries, 2000 by default, across every realm) |
A userPassword in an add or modify is stored as a scrypt hash in both modes.
In development a value that is already a $scrypt$ hash is kept as given,
which is how a directory moves between two instances. Product mode refuses one.
Let people change their own entry
In product mode a person bound as themselves may modify only the attributes
in ldap.selfWritableAttributes, on their own entry. The default list is
telephoneNumber, mobile, homePhone, displayName, preferredLanguage,
postalAddress, street, l, st, postalCode and userPassword.
Taken from the in-process test tests/directory_write_authorization.js.
ldapmodify -H ldaps://localhost:636 -x \
-D "uid=ldaps-run1,ou=users,dc=example,dc=com" -w "$PASSWORD" <<'EOF'
dn: uid=ldaps-run1,ou=users,dc=example,dc=com
changetype: modify
replace: telephoneNumber
telephoneNumber: +1 555 0100
-
replace: displayName
displayName: Probe
EOF
A modify that mixes an allowed attribute with one that is not (mail, say)
is refused 50 as a whole, and nothing in it is applied. memberOf is never
writable this way, because it grants console roles.
To change the list:
Console: on Protocols → LDAP / LDAPS, edit Attributes a person may change on their own entry and click Save LDAP.
API:
api config/set '{"key":"ldap.selfWritableAttributes","value":"telephoneNumber,mobile,userPassword"}'
Think before adding an attribute a token carries (mail, cn, givenName,
sn) or one a policy reads (employeeType): a person who can write it can
assert it about themselves. An empty list lets nobody change anything.
Publish an address book
In product mode a person bound over LDAPS reads their own entry and, by
default, nobody else’s. Another person’s DN answers 32 noSuchObject, as a
DN that does not exist does. To let people read some attributes of each
other, list them in ldap.directoryReadableAttributes. Taken from
sts_ldap_read_authorization.js, which widens the list to
objectClass,cn,uid,mail in a throwaway product-mode realm and puts it back
afterwards.
Console: on Protocols → LDAP / LDAPS, set Attributes a person may
read of other people to objectClass,cn,uid,mail, and click Save LDAP.
API:
api config/set '{"key":"ldap.directoryReadableAttributes","value":"objectClass,cn,uid,mail"}'
api config/reset '{"key":"ldap.directoryReadableAttributes"}' # back to self-only
What the job then sees, bound as an ordinary person:
- a subtree search of
ou=usersreturns the other person withmailbut withouttelephoneNumber; (telephoneNumber=*)matches only the reader, because a filter cannot see an attribute the reader may not read;- a compare of the other person’s
mailanswers, and a compare of theirtelephoneNumberis 50.
A group is visible to a person only if they are in it, and then only its
cn, description and objectClass. Members may read their group’s
member list (ldap.groupMembersReadable, off by default) shows the members
too. Administrators read everything in their scope whatever these say.
Read the warning on LDAP before widening either setting. An address book hands everybody with a password the list of usernames and addresses.
A trust realm’s own tree
Every trust realm has a tree of its own, rooted at the
RFC 2247 mapping of its DNS domain, behind the same two sockets. The DN names
the realm, so a client changes realm by changing its base. Taken from
sts_ldap_read_authorization.js, which creates one realm in each mode.
Console: on Server configuration → Trust realms, create the realm with its id and domain.
API:
api realms/create '{"id":"ldrp1","domain":"ldrp1.example.net","name":"LDAP realm",
"overrides":{"global.mode":"product"}}'
curl -sk "$BASE/realm/ldrp1/admin-api/groups" -H "Authorization: Bearer $TOKEN"
The second call answers the realm’s baseDn, usersDn and groupsDn, here
dc=ldrp1,dc=example,dc=net. A realm’s domain is fixed when it is created. A
realm created without one gets <id>.<global.domain>.
Create people and groups in the realm through /realm/<id>/admin-api, or on
the console while it shows that realm. A realm may have administrators of its
own; this one reads only its realm:
api() { curl -sk -X POST "$BASE/realm/ldrp1/admin-api/$1" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d "$2"; }
api users/create '{"username":"ldr-admin","invent":false,"credential":"password",
"password":"'"$PASSWORD"'","attributes":{"cn":"LDR ldr-admin","givenName":"LDR",
"sn":"ldr-admin","mail":"ldr-admin@ldr.test"}}'
api rbac/grant '{"username":"ldr-admin","role":"read"}'
That person then binds as uid=ldr-admin,ou=users,dc=ldrp1,dc=example,dc=net
and reads the whole realm, applications included. A modifyDN may not move an
entry from one realm to another (71, affectsMultipleDSAs).
Groups
Groups made on the console or through the API are groupOfNames entries
under ou=groups, and LDAP sees them at once. Taken from
sts_ldap_read_authorization.js.
Console:
- On Directory → Groups, under Create a group, set cn to
ldr-inand, optionally, a Description. - In Members, put one username or DN per line, for example
ldr-reader. - Click Create.
- To add somebody later, open the group, type them in User or DN under Add a member, and click Add.
API:
api groups/create '{"group":"ldr-in"}'
api groups/add-member '{"group":"ldr-in","member":"ldr-reader"}'
members on groups/create takes a list at once
("members":["alice","bob"]). A bare name resolves to that person’s own
entry, wherever it is.
Nothing maintains memberOf, and nesting is shown but never expanded.
A group grants nothing by being a group, except the groups a role names,
such as the console’s cn=admin-read and cn=admin-write. Whether tokens
carry a person’s groups is the groups.* settings on LDAP.
The password policy
A realm’s password policy is the entry cn=default,ou=passwordPolicies,<base>.
It is enforced on an LDAP write of userPassword in product mode, and a
refusal is 19 constraintViolation. Taken from the in-process test
tests/password_policy.js, which saves the profile below and then sets a
password over LDAP.
Console:
- On Directory → Policies, open the Password policy tab.
- Set Minimum length to
8, Previous passwords that may not be reused to1, Minimum number of symbols to0, Require an uppercase letter to off, Require a number to on, and Length of a generated password to16. - Click Save the profile.
API:
api policies/save-password-policy '{"minLength":8,"history":1,"minSymbols":0,
"requireUppercase":false,"requireDigit":true,"generatedLength":16}'
api policies/reset-password-policy '{}' # delete the entry; the built-in defaults apply
A save must carry the six composition fields. One that leaves a field out is refused by name for each, rather than resetting it. The built-in defaults are 12, 5, 1, on, on and 20. The generated length must be at least the minimum length.
How a password is stored
The same profile decides how a NEWLY stored password is hashed. These seven fields are optional in a save: one left out keeps the value in force.
| Field | Attribute | Default | Bounds |
|---|---|---|---|
hashAlgorithm |
stsPwdHashAlgorithm |
scrypt |
scrypt or argon2id |
argon2Memory |
stsPwdArgon2Memory |
19456 KiB (19 MiB) | 19456 to 1048576 |
argon2Iterations |
stsPwdArgon2Iterations |
2 | 2 to 16 |
argon2Parallelism |
stsPwdArgon2Parallelism |
1 | 1 to 16 |
scryptLogN |
stsPwdScryptLogN |
15 (N=32768) | 13 to 20 |
scryptBlockSize |
stsPwdScryptBlockSize |
8 | 8 to 16 |
scryptParallelism |
stsPwdScryptParallelism |
1 | 1 to 16 |
The default is scrypt at N=2^15, r=8, p=1, the parameters every password stored before 2026-10-22 already has. argon2id can be chosen instead; its bounds start at the OWASP Password Storage Cheat Sheet’s minimum.
The scrypt default is below OWASP’s scrypt table (2^17 with p=1, 2^16 with p=2, 2^15 with p=3, 2^14 with p=5, or 2^13 with p=10). Until #872 makes one of OWASP’s pairs the default, neither the policy nor the hashing code enforces the table: a policy with a scrypt cost and parallelism below it is saved, and the parallelism asked for is the one used.
An argon2id hash is stored as the PHC string
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>, and a scrypt one as
$scrypt$N$r$p$<salt>$<hash>. Each names its own parameters, so a stored hash
of either form keeps verifying after the policy changes. When a password
verifies and its stored hash is not the policy’s algorithm and parameters, it
is hashed again and written back. A change of parameters within one algorithm
counts too. The rule is “different from the policy”, not “weaker than the
policy”: a person whose stored hash is argon2id is hashed again with scrypt at
their next sign-in when the policy says scrypt. That costs one scrypt hash at
2^15, r=8, p=1 (about 100 ms) once per person. The person’s password history
and pwdChangedTime are not touched, because the password did not change.
Development mode verifies no password, so the upgrade happens in product mode
only.
Client secrets, activation and reset tokens, recovery codes and app passwords
are not passwords, and are still hashed with scrypt at
security.passwordHashLogN, security.passwordHashR and
security.passwordHashP.
Over LDAP, the policy is an ordinary entry that ldapsearch can read.
In the job, a modify of userPassword to weak is refused 19 and writes
nothing, and Ldap-Passw0rd! is accepted and stored as a hash:
ldapmodify -H ldaps://localhost:636 -x -D "$ADMIN_DN" -w "$ADMIN_PASSWORD" <<'EOF'
dn: uid=password-policy-ldap-probe,ou=users,dc=example,dc=com
changetype: modify
replace: userPassword
userPassword: Ldap-Passw0rd!
EOF
A password set over LDAP is what every other door then checks, and it gives the person Kerberos keys too.
People who hold a second factor: app passwords
An LDAP bind cannot ask for a second factor. So in product mode a person who
holds or must hold one is refused their own password at a bind, with the
same 49 a wrong password gets. They bind with an app password scoped to
ldap instead. Development mode accepts every password, so there the app
password simply works too. Taken from sts_second_factor_doors.js.
Console: on the person’s page, open the Second factors tab. Under App passwords, give it a name, tick LDAP bind, and click Make an app password. It is shown once.
API:
api users/create-app-password '{"user":"bob","name":"mail client","doors":["ldap"]}'
The answer’s appPassword (six groups of four characters) is the bind
password. The person can also make their own on /portal/app-passwords. An
app password is refused at the sign-in screen and at every door it does not
name.
Ask for, or require, a client certificate on 636
By default 636 sends no CertificateRequest. A bind is how a directory client says who it is. Two settings in the Listener: LDAPS group change that:
| Setting | Default | What it does |
|---|---|---|
ldap.ldapsDisableOptionalClientCertificate |
true |
Off asks every connection for a certificate and requires none. |
ldap.ldapsRequireClientCertificate |
false |
On refuses at the handshake any connection without a certificate that chains to the client truststore. Wins over the row above. |
A certificate presented to 636 is verified, and binds only through SASL
EXTERNAL (ldapsearch -Y EXTERNAL): as the person this service issued it
to, or — for one from an outside certificate authority — as the one person
entry that stores it in userCertificate. See LDAP, Binding with
a client certificate. A simple bind still decides who the connection is.
To store an outside certificate on the person first:
api users/add-certificate "$(jq -n --rawfile pem ldaps-run1.pem \
'{user:"ldaps-run1", certificate:$pem}')"
Console: on Server configuration → Listeners, change the row in the Listener: LDAPS group and click Save Listener: LDAPS.
API:
api config/set '{"key":"ldap.ldapsRequireClientCertificate","value":true}'
These two rows are not in any test job’s recipe; their behaviour is held by
the in-process tests tests/ldaps_no_certificate_request.js and
tests/listener_tls_policy.js.
Large searches
There is no paged-results control and no other LDAP control. A search that
matches more than ldap.sizeLimit entries (500) returns that many and ends
with 4 sizeLimitExceeded. One that runs past ldap.timeLimit seconds (30),
or the client’s own shorter -l, ends with 3 timeLimitExceeded. In both
cases the entries already sent are a valid partial answer. Narrow the base or
the filter to read the rest. Both settings are on Protocols → LDAP /
LDAPS:
api config/set '{"key":"ldap.sizeLimit","value":2000}'
What the directory does not offer
The client has to manage without these. LDAP says why for each.
- StartTLS. Use
ldaps://on 636. - SASL, including EXTERNAL and GSSAPI. Only a simple bind.
- Controls and extended operations. No paged results, no Who am I? (so
ldapwhoamidoes not work), and no Password Modify (ldappasswd). Change a password with anldapmodifyofuserPassword. - A schema. No object class or syntax is checked.
- Referential integrity. Deleting a person leaves their DN in every group.
- Replication. There is no LDAP replication protocol. Several nodes share one directory through the persistence store.
Related
- LDAP and LDAP schema: the behaviour, every setting, and the attributes.
- Configuring SCIM 2.0: provisioning into the same directory over HTTP.
- Trust realms, TLS and mutual TLS and Persistence.
- Management API: the token, and the OpenAPI document at
/admin-api/openapi.json. - Configuring OAuth 2.0 grants, Configuring OpenID Connect flows and Configuring SAML profiles.