Configuring NTLM SSO with LDAP Group Resolution for Express.js (NTLM Method)
Assumptions
This instruction assumes:
- An Express.js 4.x application already exists and is running on Linux. This instruction adds authentication to it -- it does not create the application from scratch
- Node.js 18.x or 20.x LTS is installed on the server
- The Linux server can reach the Active Directory domain controller(s) over LDAP (port 389) or LDAPS (port 636)
- An AD service account exists with read access to the directory (for LDAP group membership queries). This instruction does not cover provisioning the AD account itself
- Client browsers are on domain-joined Windows machines. Non-domain-joined clients will receive a credential prompt instead of silent SSO
- There is no HTTP reverse proxy that terminates TCP connections between the browser and the Express server. NTLM authenticates the TCP connection -- a proxy that creates a new connection to the backend breaks the NTLM handshake. A transparent proxy or L4 load balancer that passes TCP connections through without termination is acceptable
- The reader has
npmaccess to install packages - The reader understands Express middleware ordering
- AD group names are known and can be mapped to application roles. This instruction uses placeholder group and role names
- If browser trust configuration via Group Policy (Step 9, Option A) is required, domain admin access or delegated GPO management rights are needed
- The frontend permission example (Step 8) uses EJS template syntax. Readers using Pug, Handlebars, or another template engine must adapt the expression accordingly
Prerequisites
Automatic setup
npm install express-ntlm@^2.7.0 ldapjs@^3 express-session@^1
express-ntlm^2.7.0 -- NTLM authentication middleware; handles the 3-way handshake with the browser and validates the token against an AD domain controllerldapjs^3.x -- LDAP client for querying Active Directory group memberships via thememberOfattributeexpress-session^1.x -- server-side session management; caches the authenticated user so NTLM + LDAP resolution happens once per session, not per request
For multi-server deployments where sessions must be shared across instances:
npm install connect-redis@^7 ioredis@^5
Manual setup
If npm install is not available on the target server (e.g., air-gapped environment), install from a tarball:
- On a machine with npm access, pack each dependency
npm pack express-ntlm@^2.7.0
npm pack ldapjs@^3
npm pack express-session@^1
- Transfer the
.tgzfiles to the target server and install from the local files
npm install ./express-ntlm-2.7.0.tgz ./ldapjs-3.*.tgz ./express-session-1.*.tgz
Additional setup
- Verify LDAP connectivity from the Linux server
ldapsearch -H ldap://{DC_HOSTNAME} -D "{BIND_DN}" -w "{BIND_PASSWORD}" -b "{BASE_DN}" "(sAMAccountName={TEST_USERNAME})" memberOf
{DC_HOSTNAME} -- the hostname or FQDN of the Active Directory domain controller (e.g., dc01.corp.example.com).
{BIND_DN} -- the distinguished name of the LDAP service account (e.g., CN=svc-app,OU=Service Accounts,DC=corp,DC=example,DC=com).
{BIND_PASSWORD} -- the plaintext password of the service account (used here for testing only; the application uses an encrypted credential).
{BASE_DN} -- the LDAP search base (e.g., DC=corp,DC=example,DC=com).
{TEST_USERNAME} -- a known AD username to test with.
Expected output:
dn: CN=Test User,OU=Users,DC=corp,DC=example,DC=com
memberOf: CN=DBA-Admins,OU=Groups,DC=corp,DC=example,DC=com
memberOf: CN=DBA-Viewers,OU=Groups,DC=corp,DC=example,DC=com
If the command fails with "Can't contact LDAP server", check firewall rules: the Linux server must have outbound TCP access to the domain controller on port 389 (LDAP) or 636 (LDAPS).
The ldapsearch command requires the openldap-clients package (RHEL/OL) or ldap-utils (Debian/Ubuntu).
- Generate a session secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store the output as {SESSION_SECRET}. This value signs the session ID cookie. Store it in an environment variable, not in source code.
Configuration
Step 1: Create the application configuration file
Create a JSON configuration file. All environment-specific values are centralized here.
{APP_CONFIG_PATH} -- the path to the application configuration file (e.g., ./config.json).
{
"auth": {
"type": "ntlm",
"domaincontroller": "ldap://{DC_HOSTNAME}",
"ldap": {
"url": "ldap://{DC_HOSTNAME}",
"baseDN": "{BASE_DN}",
"bindDN": "{BIND_DN}",
"bindCredential": "",
"bindSalt": "",
"bindIv": "",
"bindTag": "",
"groupAttribute": "memberOf",
"usernameAttribute": "sAMAccountName"
},
"sessionTTL": 28800,
"sessionSecret": "",
"defaultRole": "viewer",
"roles": {
"admin": {
"adGroups": ["{ADMIN_AD_GROUP}"],
"permissions": ["*"]
},
"editor": {
"adGroups": ["{EDITOR_AD_GROUP}"],
"permissions": ["dashboard:view", "databases:view", "databases:edit"]
},
"viewer": {
"adGroups": ["{VIEWER_AD_GROUP}"],
"permissions": ["dashboard:view", "databases:view"]
}
}
}
}
{ADMIN_AD_GROUP} -- the CN of the AD group for administrators (e.g., DBA-Admins).
{EDITOR_AD_GROUP} -- the CN of the AD group for editors (e.g., DBA-Editors).
{VIEWER_AD_GROUP} -- the CN of the AD group for viewers (e.g., DBA-Viewers).
The sessionSecret and bindCredential/bindSalt/bindIv/bindTag fields are populated in the next steps.
For LDAPS (port 636), use ldaps://{DC_HOSTNAME} in both domaincontroller and ldap.url.
Step 2: Encrypt the LDAP bind password
The LDAP bind password is stored encrypted using AES-256-GCM with scrypt key derivation. The decryption key is derived from a passphrase stored in the APP_SECRET environment variable.
Create a utility script to generate the encrypted credential:
// generate-credential.js
const crypto = require('crypto');
const passphrase = process.env.APP_SECRET;
if (!passphrase) {
console.error('Set APP_SECRET environment variable first');
process.exit(1);
}
const plaintext = process.argv[2];
if (!plaintext) {
console.error('Usage: APP_SECRET=<passphrase> node generate-credential.js <password>');
process.exit(1);
}
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(passphrase, salt, 32);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
console.log(JSON.stringify({
bindCredential: encrypted,
bindSalt: salt.toString('hex'),
bindIv: iv.toString('hex'),
bindTag: tag.toString('hex')
}, null, 2));
Run the script:
export APP_SECRET="{APP_SECRET_PASSPHRASE}"
node generate-credential.js "{BIND_PASSWORD}"
{APP_SECRET_PASSPHRASE} -- a strong passphrase used to derive the encryption key. Store this as an environment variable on the server.
Warning: The
exportcommand records the passphrase in shell history (~/.bash_history). To avoid this, useread -s APP_SECRET && export APP_SECRETto enter the value interactively, or load it from a.envfile excluded from source control.
Expected output:
{
"bindCredential": "a1b2c3d4e5f6...",
"bindSalt": "0123456789abcdef...",
"bindIv": "abcdef012345...",
"bindTag": "fedcba987654..."
}
Copy the four values into the corresponding fields in the configuration file. Also set sessionSecret to the value generated in the Prerequisites.
Warning: Delete the
generate-credential.jsscript after use. Do not commit it to source control with embedded credentials.
Step 3: Create the credential decryption module
This module decrypts the LDAP bind password at runtime using the APP_SECRET environment variable.
// config-crypto.js
const crypto = require('crypto');
function decrypt(encryptedHex, saltHex, ivHex, tagHex) {
const passphrase = process.env.APP_SECRET;
if (!passphrase) {
throw new Error('APP_SECRET environment variable is not set');
}
const salt = Buffer.from(saltHex, 'hex');
const key = crypto.scryptSync(passphrase, salt, 32);
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
module.exports = { decrypt };
Step 4: Create the LDAP group resolution module
This module queries Active Directory for the authenticated user's group memberships.
// ldap-groups.js
const ldap = require('ldapjs');
const { decrypt } = require('./config-crypto');
function escapeLdapFilter(val) {
return val
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
function resolveGroups(username, ldapConfig) {
return new Promise(function (resolve) {
if (!ldapConfig || !ldapConfig.url || !ldapConfig.baseDN) {
return resolve([]);
}
var bindPassword;
try {
bindPassword = decrypt(
ldapConfig.bindCredential,
ldapConfig.bindSalt,
ldapConfig.bindIv,
ldapConfig.bindTag
);
} catch (err) {
console.error('Failed to decrypt LDAP bind password:', err.message);
return resolve([]);
}
var tlsOpts = ldapConfig.url.startsWith('ldaps')
? { rejectUnauthorized: false }
: {};
var client = ldap.createClient({
url: ldapConfig.url,
tlsOptions: tlsOpts
});
client.on('error', function (err) {
console.error('LDAP client error:', err.message);
resolve([]);
});
client.bind(ldapConfig.bindDN, bindPassword, function (err) {
if (err) {
console.error('LDAP bind failed:', err.message);
client.destroy();
return resolve([]);
}
var safeUsername = escapeLdapFilter(username);
var attr = ldapConfig.usernameAttribute || 'sAMAccountName';
var searchOpts = {
filter: '(' + attr + '=' + safeUsername + ')',
scope: 'sub',
attributes: [ldapConfig.groupAttribute || 'memberOf']
};
client.search(ldapConfig.baseDN, searchOpts, function (err, res) {
if (err) {
console.error('LDAP search failed:', err.message);
client.destroy();
return resolve([]);
}
var groups = [];
res.on('searchEntry', function (entry) {
var memberOf = entry.pojo.attributes.find(function (a) {
return a.type === (ldapConfig.groupAttribute || 'memberOf');
});
if (memberOf && memberOf.values) {
memberOf.values.forEach(function (dn) {
var match = dn.match(/^CN=([^,]+)/i);
if (match) {
groups.push(match[1]);
}
});
}
});
res.on('error', function (err) {
console.error('LDAP search error:', err.message);
client.destroy();
resolve([]);
});
res.on('end', function () {
client.unbind(function () {});
resolve(groups);
});
});
});
});
}
module.exports = { resolveGroups, escapeLdapFilter };
The escapeLdapFilter function prevents LDAP filter injection by escaping special characters defined in RFC 4515.
On LDAP failure at any stage (bind, search, network error), the function returns an empty array. The user authenticates via NTLM but receives no group-based permissions. This is graceful degradation, not a hard failure.
For LDAPS connections, rejectUnauthorized: false is set because internal AD domain controllers often use certificates from an internal CA that the Linux server does not trust by default. In production, install the internal CA certificate on the Linux server and set rejectUnauthorized: true for proper certificate validation.
Step 5: Create the permission resolution module
This module maps AD groups to roles to permissions.
// permissions.js
function resolvePermissions(userGroups, rolesConfig, defaultRole) {
var permissions = [];
Object.keys(rolesConfig).forEach(function (roleName) {
var role = rolesConfig[roleName];
if (!role.adGroups || !role.permissions) return;
var match = role.adGroups.some(function (adGroup) {
return userGroups.some(function (userGroup) {
return userGroup.toLowerCase() === adGroup.toLowerCase();
});
});
if (match) {
role.permissions.forEach(function (perm) {
if (permissions.indexOf(perm) === -1) {
permissions.push(perm);
}
});
}
});
if (permissions.length === 0 && defaultRole && rolesConfig[defaultRole]) {
rolesConfig[defaultRole].permissions.forEach(function (perm) {
if (permissions.indexOf(perm) === -1) {
permissions.push(perm);
}
});
}
return permissions;
}
function hasPermission(permissions, required) {
if (permissions.indexOf('*') !== -1) return true;
if (permissions.indexOf(required) !== -1) return true;
var page = required.split(':')[0];
if (permissions.indexOf(page + ':*') !== -1) return true;
return false;
}
function authorize(permission) {
return function (req, res, next) {
if (!req.user) return next();
if (hasPermission(req.user.permissions, permission)) return next();
return res.status(403).json({ success: false, error: 'Forbidden' });
};
}
module.exports = { resolvePermissions, hasPermission, authorize };
Permission matching uses three tiers:
| Pattern | Example | Matches |
|---|---|---|
| Wildcard | * |
Every permission |
| Exact | databases:delete |
Only databases:delete |
| Page wildcard | databases:* |
Any permission starting with databases: |
Step 6: Create the authentication middleware module
This module initializes the NTLM middleware and the session-user-restore middleware.
// auth.js
var crypto = require('crypto');
var session = require('express-session');
var { resolveGroups } = require('./ldap-groups');
var { resolvePermissions } = require('./permissions');
var ntlmMiddleware = null;
var enabled = false;
function init(authConfig) {
if (authConfig.type !== 'ntlm' || !authConfig.domaincontroller) {
enabled = false;
return;
}
var ntlm = require('express-ntlm');
ntlmMiddleware = ntlm({
domain: authConfig.domain || undefined,
domaincontroller: authConfig.domaincontroller,
debug: function () {
var args = Array.prototype.slice.apply(arguments);
console.log.apply(null, args);
}
});
enabled = true;
}
function middleware(req, res, next) {
if (!enabled || !ntlmMiddleware) return next();
if (req.session && req.session.user) {
return next();
}
ntlmMiddleware(req, res, function (err) {
if (err) {
console.error('NTLM handshake error:', err.message);
return res.status(401).json({ success: false, error: 'Authentication failed' });
}
if (!req.ntlm || !req.ntlm.UserName) {
return res.status(401).json({ success: false, error: 'NTLM authentication did not return a username' });
}
next();
});
}
function sessionUserMiddleware(authConfig) {
return function (req, res, next) {
if (!enabled) return next();
if (req.session && req.session.user) {
req.user = req.session.user;
return next();
}
if (!req.ntlm || !req.ntlm.UserName) return next();
resolveGroups(req.ntlm.UserName, authConfig.ldap)
.then(function (groups) {
var permissions = resolvePermissions(
groups,
authConfig.roles || {},
authConfig.defaultRole
);
req.user = {
username: req.ntlm.UserName,
domain: req.ntlm.DomainName,
groups: groups,
permissions: permissions
};
req.session.user = req.user;
next();
})
.catch(function (err) {
console.error('Group resolution error:', err.message);
req.user = {
username: req.ntlm.UserName,
domain: req.ntlm.DomainName,
groups: [],
permissions: []
};
req.session.user = req.user;
next();
});
};
}
function getSessionMiddleware(authConfig) {
var sessionOpts = {
secret: authConfig.sessionSecret || crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
name: 'ops.sid',
cookie: {
httpOnly: true,
sameSite: 'strict',
maxAge: (authConfig.sessionTTL || 28800) * 1000
}
};
return session(sessionOpts);
}
module.exports = { init, middleware, sessionUserMiddleware, getSessionMiddleware };
The session short-circuit in middleware is critical: if req.session.user already exists, the NTLM handshake is skipped entirely. NTLM + LDAP resolution happens once per session (default 8 hours), not on every request.
Step 7: Wire the middleware chain in server.js
Add the following to your existing server.js. Middleware order matters -- insert each block in the sequence shown below, before your existing route handlers.
Add the require statements at the top of server.js:
var auth = require('./auth');
var { authorize } = require('./permissions');
var config = require('{APP_CONFIG_PATH}');
var authConfig = config.auth || {};
{APP_CONFIG_PATH} -- the path to the configuration file created in Step 1 (e.g., ./config.json).
Initialize the NTLM module before any middleware:
auth.init(authConfig);
After your existing static file serving and body parser middleware, add the session, health, NTLM, and user-restore middleware in this exact order:
// Session middleware -- must be BEFORE NTLM
app.use(auth.getSessionMiddleware(authConfig));
// Health endpoint -- BEFORE authentication (always reachable by monitoring)
app.get('/api/health', function (req, res) {
res.json({ status: 'ok' });
});
// NTLM middleware -- handles browser handshake
app.use(auth.middleware);
// Session-user-restore -- populates req.user from session or LDAP
app.use(auth.sessionUserMiddleware(authConfig));
// Template locals -- makes user available in templates
app.use(function (req, res, next) {
res.locals.user = req.user || null;
next();
});
Add authorize() checks to your existing route handlers:
app.get('/api/databases', authorize('databases:view'), function (req, res) {
// ... existing route handler
});
app.delete('/api/databases/:id', authorize('databases:delete'), function (req, res) {
// ... existing route handler
});
The complete middleware chain order must be:
express.static-- no authenticationexpress.json()-- body parserauth.getSessionMiddleware()-- session- Health endpoint -- before authentication
auth.middleware-- NTLM handshakeauth.sessionUserMiddleware()-- populatereq.user- Template locals
- Route handlers with
authorize()checks
Step 8: Add frontend permission checks (optional)
Backend authorize() is the real enforcement. Frontend checks are UX-only -- they hide UI elements the user cannot act on.
Expose the user's permissions to the client in your HTML template:
<script>
window.__userPermissions = <%- JSON.stringify(user ? user.permissions : null) %>;
</script>
Add the client-side check function:
// Frontend permission check (UX only -- not security)
var perms = window.__userPermissions || null;
function can(permission) {
if (!perms) return true;
if (perms.indexOf('*') !== -1) return true;
if (perms.indexOf(permission) !== -1) return true;
var page = permission.split(':')[0];
if (perms.indexOf(page + ':*') !== -1) return true;
return false;
}
// Usage: hide a delete button if the user lacks permission
if (!can('databases:delete')) {
document.getElementById('delete-btn').style.display = 'none';
}
When perms is null (authentication disabled), can() returns true for all checks.
Step 9: Configure browser trust for silent SSO
Without browser trust configuration, domain-joined Windows browsers prompt for credentials instead of sending NTLM tokens automatically.
Option A: Group Policy (recommended for enterprise)
Configure via GPO: Computer Configuration > Administrative Templates > Windows Components > Internet Explorer > Internet Control Panel > Security Page > Site to Zone Assignment List. Add the application URL (e.g., http://{APP_HOSTNAME}) with zone value 1 (Local Intranet).
Option B: Per-machine registry
Add the application URL to the Local Intranet zone: Internet Options > Security > Local Intranet > Sites > Advanced. Add http://{APP_HOSTNAME}.
{APP_HOSTNAME} -- the hostname or FQDN of the Express server as accessed by browsers (e.g., app.corp.example.com).
Option C: Chrome/Edge policy (AuthServerAllowlist)
Set the AuthServerAllowlist registry key:
HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Google\Chrome
AuthServerAllowlist = "{APP_HOSTNAME}"
HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Edge
AuthServerAllowlist = "{APP_HOSTNAME}"
Option D: Firefox
Navigate to about:config and set:
network.automatic-ntlm-auth.trusted-uris = {APP_HOSTNAME}
Multiple hosts are separated by commas.
Post-configuration
Step 1: Start the application with the encryption passphrase
export APP_SECRET="{APP_SECRET_PASSPHRASE}"
node server.js
Expected output:
Server listening on port 3000
If the APP_SECRET environment variable is missing or incorrect, the LDAP bind password decryption fails and group resolution returns empty arrays. NTLM authentication still works, but users receive no group-based permissions.
Step 2: Verify the health endpoint responds without authentication
Open a second terminal and run:
curl -s http://localhost:3000/api/health
Expected output:
{"status":"ok"}
This confirms the health endpoint is correctly placed before the NTLM middleware in the chain.
Validation
Quick check
Open the application URL in a domain-joined Windows browser. The page should load without a credential prompt. This confirms NTLM silent SSO is working.
Full validation
- Check NTLM handshake in server logs
The first request from a new session triggers the NTLM 3-way handshake (three HTTP requests: negotiate, challenge, authenticate). Subsequent requests within the same session use the cached session and show no NTLM activity.
- Verify group resolution
Add a temporary debug endpoint to server.js (after the auth middleware chain):
app.get('/api/debug/me', authorize('*'), function (req, res) {
res.json(req.user || { auth: 'disabled' });
});
Access http://{APP_HOSTNAME}:{APP_PORT}/api/debug/me from a domain-joined browser. Verify groups contains the expected AD group CNs and permissions contains the union of all matching role permissions. Remove this endpoint after validation.
- Test multi-group user
Authenticate with a user who is a member of multiple AD groups that map to different roles. Verify the user receives the union of permissions from all matching roles.
- Test LDAP failure graceful degradation
Stop the LDAP service or configure incorrect bind credentials. Verify the application does not crash. The user authenticates via NTLM but receives empty permissions (no group-based access).
- Test non-domain-joined client
Access the application from a machine that is not joined to the domain. Verify a credential prompt appears and that manually entered domain credentials work.
- Test session persistence
Authenticate, then close and reopen the browser (or clear the session cookie). Verify a new NTLM handshake occurs on the next request (visible in server logs).
- Test authorization enforcement
Attempt to access a protected endpoint without the required permission. Verify the server returns HTTP 403:
curl -s -w "\n%{http_code}" http://localhost:{APP_PORT}/api/databases
If the user lacks databases:view, the response is:
{"success":false,"error":"Forbidden"}
403
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Browser shows a credential prompt instead of silent SSO | Application URL is not in the browser's Local Intranet zone | Add the URL to Local Intranet via GPO, registry, or browser settings (see Step 9) |
NTLM handshake error in server logs |
The domain controller is unreachable from the Linux server on port 389/636 | Verify connectivity: ldapsearch -H ldap://{DC_HOSTNAME} -x -s base |
req.ntlm is undefined after middleware runs |
express-ntlm is not in the middleware chain, or another middleware called next() before it completed |
Verify middleware ordering in server.js matches the chain in Step 7 |
| LDAP bind fails with "Invalid credentials" | Wrong bind DN or bind password, or the encrypted credential does not match the APP_SECRET |
Re-run generate-credential.js with the correct password and verify APP_SECRET matches |
| LDAP search returns empty groups | The base DN does not contain the user, or the service account lacks read access to memberOf |
Test with ldapsearch from the command line using the same bind DN and base DN |
| User authenticates but has no permissions | LDAP group resolution failed silently (graceful degradation), or AD group names in the config do not match the CNs returned by LDAP | Check server logs for LDAP errors; verify group names are case-insensitive matches |
APP_SECRET environment variable is not set |
The APP_SECRET variable was not exported before starting the application |
Run export APP_SECRET="{APP_SECRET_PASSPHRASE}" before node server.js |
| Session is not persisted between requests | express-session is missing from the middleware chain, or saveUninitialized is true and no data is written |
Verify session middleware is placed before NTLM middleware; set saveUninitialized: false |
| NTLM works locally but fails behind a reverse proxy | The proxy terminates the TCP connection, breaking the NTLM handshake | Use a transparent proxy or L4 load balancer that passes TCP through without termination |
DEPTH_ZERO_SELF_SIGNED_CERT when using LDAPS |
The domain controller's TLS certificate is not trusted by the Linux server | Set rejectUnauthorized: false in the LDAP TLS options, or install the internal CA certificate on the server |
References
- express-ntlm -- GitHub
- ldapjs Client API -- GitHub (repository archived May 2024; library still functional)
- express-session -- GitHub
- Microsoft NTLM Over HTTP Protocol (MS-NTHT)
- Microsoft NTLM Overview
- RFC 4515 -- String Representation of Search Filters