OpenLDAP schema for storing TOTP (Time-based One-Time Password) secrets and managing multi-factor authentication policies in your LDAP directory.
This schema extends your LDAP directory with attributes and object classes for implementing TOTP-based two-factor authentication. It provides a centralised, secure location for storing TOTP secrets and enforcing MFA policies across your infrastructure.
Use cases:
- Centralised TOTP secret storage for authentication systems
- MFA policy enforcement at the group level
- Grace period management for new user onboarding
- Backup code storage for account recovery
- Integration with PAM, web applications, VPN servers, or custom authentication systems
The easiest way to get started is to use the setup script, which downloads the latest release and customises the LDIF files for your directory:
# Download and run - provide your base DN as an argument
curl -sL https://raw.githubusercontent.com/wheelybird/ldap-totp-schema/main/setup.sh | bash -s -- dc=example,dc=comOr clone the repository and run it locally:
git clone https://github.com/wheelybird/ldap-totp-schema.git
cd ldap-totp-schema
# With argument (non-interactive)
./setup.sh dc=example,dc=com
# Or interactively (will prompt for base DN)
./setup.shThe script will:
- Use the provided base DN (or prompt you for one if not provided)
- Download the latest release from GitHub
- Create customised LDIF files in
./ldap-totp-schema-configured/
The osixia/openldap container supports adding custom schemas and LDIF files at startup. After running the setup script, mount the generated files as volumes:
docker run \
--detach \
--name openldap \
--hostname openldap \
-p 389:389 \
-e LDAP_ORGANISATION="Example Company" \
-e LDAP_DOMAIN="example.com" \
-e LDAP_ADMIN_PASSWORD="admin_password" \
-e LDAP_TLS_VERIFY_CLIENT="never" \
-e "LDAP_RFC2307BIS_SCHEMA=true" \
--volume /opt/docker_data/openldap/var_lib_ldap:/var/lib/ldap \
--volume /opt/docker_data/openldap/etc_ldap_slapd.d:/etc/ldap/slapd.d \
--volume ./ldap-totp-schema-configured/totp-schema.ldif:/container/service/slapd/assets/config/bootstrap/schema/custom/totp-schema.ldif:ro \
--volume ./ldap-totp-schema-configured/totp-acls.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/totp-acls.ldif:ro \
osixia/openldap:latest \
--copy-serviceNotes:
- The schema file goes in
.../schema/custom/(loaded before data) - The ACL file goes in
.../ldif/custom/(applied after schema) - Use
:ro(read-only) to prevent the container from modifying your source files - Use the
--copy-serviceargument to allow osixia/openldap to install the LDIF files properly - Replace
/opt/docker_data/openldap/...with your preferred data directory - Set
LDAP_DOMAINto match your base DN (e.g.,example.comfordc=example,dc=com)
If you're adding the schema to an existing container, you can apply it manually:
# Copy files into the container
docker cp ./ldap-totp-schema-configured/totp-schema.ldif openldap:/tmp/
docker cp ./ldap-totp-schema-configured/totp-acls.ldif openldap:/tmp/
# Apply schema and ACLs
docker exec openldap ldapadd -Y EXTERNAL -H ldapi:/// -f /tmp/totp-schema.ldif
docker exec openldap ldapmodify -Y EXTERNAL -H ldapi:/// -f /tmp/totp-acls.ldif| Attribute | OID | Type | Description |
|---|---|---|---|
totpSecret |
1.3.6.1.4.1.64419.1.1.1 | Single-value | TOTP shared secret (Base32 encoded, e.g., JBSWY3DPEHPK3PXP) |
totpScratchCode |
1.3.6.1.4.1.64419.1.1.2 | Multi-value | Backup/recovery codes for emergency access (plain 8-digit codes, e.g., 12345678) |
totpEnrolledDate |
1.3.6.1.4.1.64419.1.1.3 | Single-value | Timestamp when user enrolled in MFA (GeneralisedTime format) |
totpStatus |
1.3.6.1.4.1.64419.1.1.4 | Single-value | Enrolment status: none, pending, active, disabled, bypassed |
mfaRequired |
1.3.6.1.4.1.64419.1.1.5 | Single-value | Boolean flag for group-level MFA requirement |
mfaGracePeriodDays |
1.3.6.1.4.1.64419.1.1.6 | Single-value | Number of days grace period before MFA enforcement |
| Object Class | OID | Type | Attributes |
|---|---|---|---|
totpUser |
1.3.6.1.4.1.64419.1.2.1 | Auxiliary | totpSecret, totpScratchCode, totpEnrolledDate, totpStatus |
mfaGroup |
1.3.6.1.4.1.64419.1.2.2 | Auxiliary | mfaRequired, mfaGracePeriodDays |
Note: Both object classes are auxiliary, meaning they extend existing user and group entries without replacing the structural object class.
- OpenLDAP 2.4+ with
cn=config(OLC) configuration - Root or LDAP admin access to add schemas
- LDAP server running locally or accessible via LDAPI
Verify OpenLDAP version:
slapd -V
# Should show: @(#) $OpenLDAP: slapd 2.4.x or higherOption A: Clone from Git (recommended)
git clone https://github.com/wheelybird/ldap-totp-schema.git
cd ldap-totp-schemaOption B: Download Release
wget https://github.com/wheelybird/ldap-totp-schema/archive/v1.0.0.tar.gz
tar xzf v1.0.0.tar.gz
cd ldap-totp-schema-1.0.0sudo ldapadd -Y EXTERNAL -H ldapi:/// -f totp-schema.ldifExpected output:
adding new entry "cn=totp,cn=schema,cn=config"
Verify installation:
ldapsearch -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(cn=*totp*)"
# Should return: cn=totp,cn=schema,cn=configTroubleshooting:
- If you get "Invalid credentials", ensure you're running as root or with sudo
- If you get "Already exists", the schema is already installed
- If you get "No such object", verify your LDAP server is using
cn=config
The totp-acls.ldif file provides secure access controls for TOTP secrets. Edit the file to match your directory structure:
# Edit totp-acls.ldif and replace:
# - dc=example,dc=com with your base DN
# - ou=services,dc=example,dc=com with your services OU (if applicable)
ldapmodify -Y EXTERNAL -H ldapi:/// -f totp-acls.ldifAccess control summary:
totpSecret: Writable by self and admins; readable by designated service accountstotpScratchCode: Readable by self; writable by admins and service accounts (to remove used codes)totpStatus,totpEnrolledDate: Readable by self, admins, and authorised services; writable by adminsmfaRequired,mfaGracePeriodDays: Readable by all authenticated users; writable by admins
If you're using this schema with PAM modules such as pam-ldap-totp-auth you'll might want to use a service account that can:
- Bind to LDAP and search for users (for password authentication)
- Read
totpSecretfor OTP verification - Read
totpStatusto check if MFA is enabled - Write to
totpScratchCodeto remove used backup codes
Create the service account:
ldapadd -x -D "cn=admin,dc=example,dc=com" -w admin_password -f service-account.ldifGenerate a secure password for the service account:
# Generate a random password
PASSWORD=$(openssl rand -base64 32)
echo "Service account password: $PASSWORD"
# Hash it for LDAP
HASHED=$(slappasswd -s "$PASSWORD")
echo "Hashed password: $HASHED"
# Update the service-account.ldif file with the hashed password
# or use ldapmodify to update it after creationConfigure pam-ldap-totp-auth to use this service account:
# /etc/security/pam_ldap_totp_auth.conf
totp_enabled true
totp_mode challenge
ldap_uri ldap://ldap.example.com
ldap_base dc=example,dc=com
tls_mode starttls
tls_verify_cert true
tls_ca_cert_file /etc/ssl/certs/ca-certificates.crt
Required ACLs for PAM integration:
Your totp-acls.ldif should include these rules (adjust to match your service account DN):
# User passwords - allow service account to authenticate users
olcAccess: {0}to attrs=userPassword
by self write
by anonymous auth
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by dn.exact="cn=nslcd,ou=services,dc=example,dc=com" auth
by * none
# TOTP secret - read-only for OTP verification
olcAccess: {1}to attrs=totpSecret
by self write
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by dn.exact="cn=nslcd,ou=services,dc=example,dc=com" read
by * none
# TOTP scratch codes - write access to remove used codes
olcAccess: {2}to attrs=totpScratchCode
by self read
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by dn.exact="cn=nslcd,ou=services,dc=example,dc=com" write
by * none
# TOTP status and enrolment - read access to check if MFA is active
olcAccess: {3}to attrs=totpStatus,totpEnrolledDate
by self read
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by dn.exact="cn=nslcd,ou=services,dc=example,dc=com" read
by * none
# MFA policy attributes - check if MFA is required
olcAccess: {4}to attrs=mfaRequired,mfaGracePeriodDays
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by users read
by * none
# General user attributes - service account needs to search for users
olcAccess: {5}to dn.subtree="ou=people,dc=example,dc=com"
by dn.exact="cn=nslcd,ou=services,dc=example,dc=com" read
by * break
Step 1: Generate a TOTP secret
Generate a 160-bit (32-character Base32) secret:
# Using OpenSSL and base32 encoding
SECRET=$(openssl rand -base64 20 | base32 | tr -d '=' | head -c 32)
echo $SECRET
# Example output: JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPStep 2: Add TOTP attributes to user
ldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
add: objectClass
objectClass: totpUser
-
add: totpSecret
totpSecret: JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP
-
add: totpStatus
totpStatus: active
-
add: totpEnrolledDate
totpEnrolledDate: 20251020120000Z
EOFStep 3: Generate QR code for user enrolment
The user scans this with their authenticator app (Google Authenticator, Authy, etc.):
otpauth://totp/Example:jdoe?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
Generate QR code:
# Using qrencode
echo "otpauth://totp/Example:jdoe?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Example" | qrencode -t UTF8Generate and store 10 backup codes for emergency access:
# Generate backup codes (8 digits each)
for i in {1..10}; do
printf "%08d\n" $((RANDOM * RANDOM % 100000000))
done
# Add to user entry
ldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
add: totpScratchCode
totpScratchCode: 12345678
totpScratchCode: 87654321
totpScratchCode: 11223344
totpScratchCode: 44332211
totpScratchCode: 56789012
totpScratchCode: 21098765
totpScratchCode: 98765432
totpScratchCode: 23456789
totpScratchCode: 34567890
totpScratchCode: 45678901
EOFImportant: Backup codes should be one-time use. Your authentication system should delete them from LDAP after use.
ldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: cn=admins,ou=groups,dc=example,dc=com
changetype: modify
add: objectClass
objectClass: mfaGroup
-
add: mfaRequired
mfaRequired: TRUE
-
add: mfaGracePeriodDays
mfaGracePeriodDays: 7
EOFldapsearch -x -D "cn=admin,dc=example,dc=com" -w admin_password \
-b "ou=people,dc=example,dc=com" \
"(uid=jdoe)" totpStatus totpEnrolledDate totpSecret
# Returns:
# dn: uid=jdoe,ou=people,dc=example,dc=com
# totpStatus: active
# totpEnrolledDate: 20251020120000Z
# totpSecret: JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPDisable MFA for user:
ldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
replace: totpStatus
totpStatus: disabled
EOFMark as pending (grace period active):
ldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
replace: totpStatus
totpStatus: pending
-
replace: totpEnrolledDate
totpEnrolledDate: $(date -u +"%Y%m%d%H%M%SZ")
EOFldapmodify -x -D "cn=admin,dc=example,dc=com" -w admin_password <<EOF
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
delete: totpSecret
-
delete: totpScratchCode
-
delete: totpStatus
-
delete: totpEnrolledDate
-
delete: objectClass
objectClass: totpUser
EOF- Length: 160 bits (32 Base32 characters) recommended by RFC 6238
- Encoding: Base32 (characters A-Z and 2-7)
- Example:
JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP
Standard RFC 6238 parameters:
- Algorithm: SHA1 (widely supported)
- Digits: 6
- Time Step: 30 seconds
When validating TOTP codes, account for clock drift by accepting codes from:
- Current time window
- Previous time window (30 seconds ago)
- Next time window (30 seconds ahead)
This provides a ±90 second tolerance window.
The grace period allows users time to set up MFA after account creation or MFA requirement:
- Check if user is in group with
mfaRequired=TRUE - If
totpStatus=pending, checktotpEnrolledDate - Calculate days elapsed:
(current_date - totpEnrolledDate) / 86400 - If days elapsed >
mfaGracePeriodDays, enforce MFA requirement
- Never store TOTP secrets in plaintext in application code or logs
- Use LDAP ACLs to restrict
totpSecretaccess to authorised services only - Secrets should only be transmitted over TLS/SSL connections
- Consider using LDAP's built-in encryption (SSHA) for additional protection at rest
The provided totp-acls.ldif implements these security rules:
- Users can write their own
totpSecret(for self-enrolment) and read their owntotpScratchCode(to know remaining backup codes) - Users cannot write their own
totpScratchCode(prevents MFA bypass) - Admins can read and write all TOTP attributes
- Service accounts need explicit permissions (see Installation section 3 for PAM integration example)
- Other users cannot read TOTP secrets or scratch codes
Important security consideration: Users are given read-only access to their scratch codes. This allows them to view how many backup codes remain, but prevents them from adding new codes to bypass MFA. Only administrators can add, modify, or manually remove scratch codes. The authentication service account can remove codes automatically when they are used during login.
This schema uses OID prefix 1.3.6.1.4.1.64419 (IANA-assigned private enterprise number).
OID Structure:
1.3.6.1.4.1.64419 Enterprise number
1.3.6.1.4.1.64419.1 LDAP schema
1.3.6.1.4.1.64419.1.1.x Attribute types
1.3.6.1.4.1.64419.1.2.x Object classes
If you prefer to use your own enterprise number, you can:
- Register at https://pen.iana.org/pen/PenApplication.page
- Replace all instances of
1.3.6.1.4.1.64419intotp-schema.ldif
ldap_add: Other (e.g., implementation specific) error (80)
additional info: olcAttributeTypes: Duplicate attributeType
Cause: Schema is already loaded in your directory.
Solution: Check existing schemas:
ldapsearch -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(cn=*totp*)"ldap_search: Insufficient access (50)
Cause: ACLs are processed in order - first match wins.
Solution: Check ACL order and placement:
ldapsearch -Y EXTERNAL -H ldapi:/// -b "cn=config" "(olcAccess=*)" olcAccessEnsure TOTP ACLs appear before more general "allow read" rules.
Important: LDAP schemas cannot be deleted once added.
Workarounds:
- Stop using the attributes in your directory
- Add new attributes with different OIDs if changes are needed
- Migrate data to new attributes
- (Last resort) Rebuild LDAP directory from scratch
This schema is part of a complete LDAP-backed MFA solution:
-
pam-ldap-totp-auth - PAM module for LDAP password and TOTP authentication
- Use with SSH, sudo, login, OpenVPN, and other PAM-enabled services
- Reads TOTP secrets from this schema
- Supports grace periods and backup codes
-
Luminary - Web UI for LDAP user management with MFA enrollment
- Self-service TOTP enrollment with QR codes
- Admin user/group management
- MFA status dashboard
- Backup code generation
-
openvpn-server-ldap-otp - OpenVPN server with LDAP and TOTP support
- Docker container with pre-configured PAM stack
- Ready-to-use OpenVPN with MFA
- RFC 6238 - TOTP: Time-Based One-Time Password Algorithm
- RFC 4226 - HOTP: HMAC-Based One-Time Password Algorithm
- RFC 4512 - LDAP: Directory Information Models
- RFC 4517 - LDAP: Syntaxes and Matching Rules
- RFC 2252 - LDAP Attribute Syntax Definitions
MIT Licence - see LICENSE file for details.
Contributions welcome! Please open an issue or pull request on GitHub.