TLS


https://www.openldap.org/doc/admin26/tls.html
https://www.port389.org/docs/389ds/howto/howto-ssl.html
https://www.freeipa.org/page/V4/Automatic_Certificate_Request_Generation
https://learn.microsoft.com/en-us/entra/identity/domain-services/tutorial-configure-ldaps

1. Important Points#

Production LDAP should not send credentials over cleartext network.

preferred:
    LDAPS on 636
    or StartTLS on 389 with strict certificate verification

avoid:
    ldap:// over untrusted network
    anonymous bind in production
    insecure certificate verification
    expired self-signed certs nobody monitors

LDAPS vs StartTLS:

Mode Port Meaning Notes
LDAPS 636 TLS from connection start operationally simple
StartTLS 389 plaintext connection upgraded to TLS standards-friendly, must enforce upgrade
Plain LDAP 389 no TLS dev/local only

2. Server Configuration#

certificate layout#

/etc/ldap/tls/
    ldap.example.com.crt
    ldap.example.com.key
    ca.crt

permissions:
    private key readable only by LDAP service user
    cert and CA readable by LDAP service
    cert SAN contains ldap.example.com

OpenLDAP example#

Use cn=config to point slapd to certificate files:

dn: cn=config
changetype: modify
replace: olcTLSCACertificateFile
olcTLSCACertificateFile: /etc/ldap/tls/ca.crt
-
replace: olcTLSCertificateFile
olcTLSCertificateFile: /etc/ldap/tls/ldap.example.com.crt
-
replace: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/tls/ldap.example.com.key

Apply:

ldapmodify -H ldapi:/// \
  -Y EXTERNAL \
  -f tls.ldif

sudo systemctl restart slapd

Enable LDAPS listener in service config according to distro packaging. Common listener set:

ldap:/// ldapi:/// ldaps:///

389 Directory Server example#

389 DS manages TLS through NSS database and dsconf tooling. Exact commands vary by version and deployment method.

Checklist:

import CA
import server certificate and private key
enable TLS listener
require secure binds where appropriate
restart instance
verify with ldapsearch over ldaps

Active Directory / managed directory#

AD DS LDAPS usually requires a certificate on domain controllers with proper Server Authentication usage and SAN matching the DC name. Managed directory products often require uploading a certificate chain and enabling secure LDAP from the service console/API.

Checklist:

certificate:
    trusted by clients
    has correct EKU for server auth
    SAN matches LDAP hostname
    private key installed on domain controller/service

network:
    allow 636 only from trusted clients
    do not expose LDAPS publicly

3. Client Configuration / Verify#

Verify LDAPS:

openssl s_client -connect ldap.example.com:636 -servername ldap.example.com </dev/null

Verify RootDSE:

LDAPTLS_CACERT=/etc/ssl/company-ca.pem \
ldapsearch -H ldaps://ldap.example.com:636 \
  -x \
  -b "" \
  -s base \
  namingContexts supportedLDAPVersion

Verify bind:

LDAPTLS_CACERT=/etc/ssl/company-ca.pem \
ldapwhoami -H ldaps://ldap.example.com:636 \
  -x \
  -D "uid=svc-keycloak,ou=service-accounts,dc=example,dc=com" \
  -W

Expected:

certificate verify ok
hostname matches certificate SAN
ldapsearch returns namingContexts
ldapwhoami returns service account DN

Keycloak connection:

Connection URL:
    ldaps://ldap.example.com:636

Bind DN:
    uid=svc-keycloak,ou=service-accounts,dc=example,dc=com

Users DN:
    ou=people,dc=example,dc=com

Trust:
    import LDAP CA into Keycloak runtime trust store or container trust store

4. Java#

JNDI LDAPS example:

import java.util.Hashtable;
import javax.naming.Context;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;

public class LdapCheck {
  public static void main(String[] args) throws Exception {
    Hashtable<String, String> env = new Hashtable<>();
    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
    env.put(Context.PROVIDER_URL, "ldaps://ldap.example.com:636");
    env.put(Context.SECURITY_AUTHENTICATION, "simple");
    env.put(Context.SECURITY_PRINCIPAL, "uid=svc-keycloak,ou=service-accounts,dc=example,dc=com");
    env.put(Context.SECURITY_CREDENTIALS, System.getenv("LDAP_BIND_PASSWORD"));

    DirContext ctx = new InitialDirContext(env);
    System.out.println(ctx.getNameInNamespace());
    ctx.close();
  }
}

Custom truststore:

keytool -importcert \
  -alias company-root-ca \
  -file company-root-ca.crt \
  -keystore truststore.p12 \
  -storetype PKCS12

java \
  -Djavax.net.ssl.trustStore=truststore.p12 \
  -Djavax.net.ssl.trustStorePassword=changeit \
  LdapCheck

Do not disable endpoint identification / certificate validation in production.

5. Python#

Python with ldap3:

import os
from ldap3 import Server, Connection, Tls
import ssl

tls = Tls(
    ca_certs_file="/etc/ssl/company-ca.pem",
    validate=ssl.CERT_REQUIRED,
    version=ssl.PROTOCOL_TLS_CLIENT,
)

server = Server("ldap.example.com", port=636, use_ssl=True, tls=tls)
conn = Connection(
    server,
    user="uid=svc-keycloak,ou=service-accounts,dc=example,dc=com",
    password=os.environ["LDAP_BIND_PASSWORD"],
    auto_bind=True,
)

conn.search(
    search_base="ou=people,dc=example,dc=com",
    search_filter="(uid=alice)",
    attributes=["uid", "cn", "mail"],
)

print(conn.entries)
conn.unbind()

Do not use no-verify TLS settings in production.

6. Go#

Go with go-ldap:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"os"

	ldap "github.com/go-ldap/ldap/v3"
)

func main() {
	ca, err := os.ReadFile("/etc/ssl/company-ca.pem")
	if err != nil {
		panic(err)
	}
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(ca)

	conn, err := ldap.DialURL(
		"ldaps://ldap.example.com:636",
		ldap.DialWithTLSConfig(&tls.Config{
			ServerName: "ldap.example.com",
			MinVersion: tls.VersionTLS12,
			RootCAs:    pool,
		}),
	)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	err = conn.Bind(
		"uid=svc-keycloak,ou=service-accounts,dc=example,dc=com",
		os.Getenv("LDAP_BIND_PASSWORD"),
	)
	if err != nil {
		panic(err)
	}
}

Do not set InsecureSkipVerify: true in production.

7. Node.js#

Node.js with ldapts:

import { readFileSync } from "node:fs";
import { Client } from "ldapts";

const client = new Client({
  url: "ldaps://ldap.example.com:636",
  tlsOptions: {
    ca: [readFileSync("/etc/ssl/company-ca.pem")],
    servername: "ldap.example.com",
    minVersion: "TLSv1.2",
  },
});

await client.bind(
  "uid=svc-keycloak,ou=service-accounts,dc=example,dc=com",
  process.env.LDAP_BIND_PASSWORD,
);

const result = await client.search("ou=people,dc=example,dc=com", {
  scope: "sub",
  filter: "(uid=alice)",
  attributes: ["uid", "cn", "mail"],
});

console.log(result.searchEntries);
await client.unbind();

Do not use NODE_TLS_REJECT_UNAUTHORIZED=0 or rejectUnauthorized: false in production.

8. Rotation Checklist#

before rotation:
    issue certificate with correct SAN
    distribute new CA/intermediate to clients
    update Keycloak trust
    update app trust stores
    test ldapsearch against staging

during rotation:
    replace server certificate
    restart/reload LDAP service if required
    verify ldaps with openssl and ldapsearch
    verify Keycloak User Federation sync
    monitor failed bind/search rate

after rotation:
    remove old cert after overlap window
    update certificate inventory
    confirm expiry alert sees new date