
In many companies, users and passwords live in a central directory, not in each application’s database. Microsoft Active Directory (AD) is the usual implementation; apps talk to it using the LDAP protocol (often LDAPS over TLS). A typical internal web app does three things: bind to the directory with a service account, verify the user’s password against AD, read a few attributes (name, email, login name), then issue its own session—here a signed JWT—so the SPA can call APIs without sending the password again.
This post explains that model in general terms, then walks through how this repository implements it: routes, POST /auth, JWT middleware, and where the React app stores and sends the token.
Why enterprises use LDAP / AD
| Idea | Role |
|---|---|
| Single source of truth | HR disables an account in AD; every integrated app respects that without its own user table for passwords. |
| LDAP | Protocol for querying and authenticating against a directory. |
| AD | Microsoft’s directory; exposes LDAP/LDAPS endpoints your Node server reaches from the datacenter or VPN. |
| App-specific JWT | After AD says “password OK,” your API mints a short-lived token for API calls. The directory is not involved on every HTTP request. |
The app never stores employee passwords in MySQL; it delegates the password check to Active Directory via activedirectory2.
End-to-end workflow
At a high level:
- User opens
/→ Login (email@liquid.tech+ password). - Browser
POSTshttp://localhost:4008/authwith JSON body. - Server
ad.authenticate→ad.findUser→ buildsafeUser→jwt.sign. - Client saves
auth_tokeninlocalStorageand navigates to/transactions. - Screens that need identity call
GET /me(or/dashboard) withAuthorization: Bearer <token>.
Routing (React Router): login lives under AuthLayout at / and /verify; staff and dashboard use StaffLayout. There is no centralized route guard in code—the server still enforces JWT on protected endpoints.
{/* Auth */}
<Route path="/" element={<AuthLayout />}>
<Route index element={<Login />} />
<Route path="verify" element={<Verify />} />
</Route>
{/* Staff */}
<Route path="/" element={<StaffLayout />}>
<Route path="transactions">
<Route index element={<Transactions />} />
<Route path=":id" element={<TransactionView />} />
</Route>
<Route path="financialstatements" element={<FinancialStatements/>} />
<Route path="analytics" element={<Analytics />} />
</Route>
<Route path="dashboard" element={<StaffLayout />}>
<Route index element={<Dashboard />} />
</Route>
The /verify route renders a placeholder today; a future email-link or MFA step could live there without changing the LDAP login core.
import React from 'react'
const Verify = () => {
return (
<div>verify</div>
)
}
export default Verify
Binding the directory service account
The server creates one ActiveDirectory client using environment-driven URL, base DN, and a service username/password (for LDAP bind—not the end user’s password on every request). tlsOptions.rejectUnauthorized: false is common in lab environments; production often pins proper CA validation.
const ad = new ActiveDirectory({
url: process.env.LDAP_PRIMARY_HOSTS,
baseDN: process.env.LDAP_PRIMARY_BASE_DN,
username: process.env.LDAP_PRIMARY_USERNAME,
password: process.env.LDAP_PRIMARY_PASSWORD,
tlsOptions: { rejectUnauthorized: false },
});
Operations: the API process must reach LDAP_PRIMARY_HOSTS (typically corporate network or VPN). If AD is unreachable, login fails even when the UI loads.
POST /auth: authenticate, then enrich user
The login route:
- Validates email + password and restricts email to
@liquid.tech(application policy). -
ad.authenticate(email, password)— AD validates the password. -
ad.findUser(email)— loads attributes for display and JWT claims. - Builds
safeUser(no password hash; onlycn,displayName,mail,sAMAccountName). - Signs a JWT with
JWT_SECRET, expiry fromJWT_EXPIRATIONor 1 hour.
app.post("/auth", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ message: "Email and password required" });
if (!email.toLowerCase().endsWith("@liquid.tech")) return res.status(400).json({ message: "Use company email" });
ad.authenticate(email, password, async (authErr, auth) => {
if (authErr) return res.status(500).json({ message: "LDAP auth failed", details: authErr.message });
if (!auth) return res.status(401).json({ message: "Invalid email/password" });
ad.findUser(email, (userErr, user) => {
if (userErr) return res.status(500).json({ message: "User fetch failed", details: userErr.message });
if (!user) return res.status(404).json({ message: "User not found" });
const safeUser = {
cn: user.cn || "",
displayName: user.displayName || "",
mail: user.mail || "",
sAMAccountName: user.sAMAccountName || "",
};
const token = jwt.sign(
{ email: safeUser.mail, cn: safeUser.cn, sAMAccountName: safeUser.sAMAccountName },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRATION || "1h" }
);
res.json({ message: "Auth successful", user: safeUser, token });
});
});
});
JWT verification on protected APIs
After login, password is gone from the wire; APIs rely on Authorization: Bearer. Middleware verifies the signature and expiry, then sets req.user from the JWT payload.
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) return res.status(401).json({ message: "Authorization header missing" });
const token = authHeader.split(" ")[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ message: "Invalid or expired token" });
req.user = user;
next();
});
};
app.get("/dashboard", authenticateJWT, (req, res) => res.json({ message: `Welcome ${req.user.cn}`, user: req.user }));
app.get("/me", authenticateJWT, (req, res) => res.json({ user: req.user }));
Note: 401 is used when the Authorization header is missing; 403 when the token is invalid or expired—clients should handle both.
React: login and token storage
The login page calls the auth endpoint, persists auth_token, and redirects into the staff area.
const handleLogin = async (e) => {
e.preventDefault();
setLoading(true)
setError('')
setUserInfo(null)
try {
const response = await fetch('http://localhost:4008/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await response.json()
if (!response.ok) {
setError(data.message || 'Login failed')
} else {
setUserInfo(data.user)
localStorage.setItem("auth_token", data.token)
console.log('logged in user:', data.user)
navigate('/transactions')
}
} catch (err) {
setError('Server unreachable')
console.error(err)
} finally {
setLoading(false)
}
}
React: calling /me with the Bearer token
Profile and the header profile widget load the current user from GET /me using the stored token.
useEffect(() => {
const token = localStorage.getItem("auth_token");
if (!token) return;
const url = `http://localhost:4008/me`;
fetch(url, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(res => res.json())
.then(data => setUserInfo(data.user))
.catch(err => console.error("Failed to load user", err));
}, [userId]);
useEffect (()=> {
const token = localStorage.getItem("auth_token")
if (!token) return;
fetch("http://localhost:4008/me", {
headers:{
Authorization: `Bearer ${token}`
}
})
.then(res => res.json())
.then(data => setUserInfo(data.user))
.catch(() => console.error("Failed to load user"));
}, [])
Other admin API routes may not yet attach this header everywhere—server-side authenticateJWT is the real gate for protected HTTP APIs.
CORS and the dev SPA
The API allows the Vite dev origin so the browser may send credentialed requests during local development.
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.use(bodyParser.json());
Deploying to production requires updating origin (or using a whitelist) to match the real frontend URL.
Environment variables (reference)
| Variable | Purpose |
|---|---|
LDAP_PRIMARY_HOSTS |
LDAP/LDAPS server URL(s) |
LDAP_PRIMARY_BASE_DN |
Search base in the directory |
LDAP_PRIMARY_USERNAME / LDAP_PRIMARY_PASSWORD
|
Service account bind |
JWT_SECRET |
Sign and verify JWTs |
JWT_EXPIRATION |
Optional; default 1h in code |
Never commit real values; keep them in .env on the server.
Security and operations summary
- AD/LDAP handles passwords; the app stores only a JWT and display attributes in responses.
- JWTs are stateless: revoking access before expiry needs extra design (short TTL, refresh, blocklist).
-
localStorageis convenient for SPAs but is XSS-sensitive; mitigate with strict CSP and careful rendering. - Network: LDAP must be reachable from the Node host; enterprise setups often assume LAN/VPN or a jump host.
Together, this matches a common enterprise pattern: directory-backed login, app-issued bearer tokens, and JWT middleware on APIs—implemented here with Express, activedirectory2, jsonwebtoken, and a React + Vite client.

Top comments (0)