DEV Community

Felix Jumason
Felix Jumason

Posted on

Enterprise authentication with LDAP and Active Directory

LDAP
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

flow

At a high level:

  1. User opens /Login (email @liquid.tech + password).
  2. Browser POSTs http://localhost:4008/auth with JSON body.
  3. Server ad.authenticatead.findUser → build safeUserjwt.sign.
  4. Client saves auth_token in localStorage and navigates to /transactions.
  5. Screens that need identity call GET /me (or /dashboard) with Authorization: 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Validates email + password and restricts email to @liquid.tech (application policy).
  2. ad.authenticate(email, password) — AD validates the password.
  3. ad.findUser(email) — loads attributes for display and JWT claims.
  4. Builds safeUser (no password hash; only cn, displayName, mail, sAMAccountName).
  5. Signs a JWT with JWT_SECRET, expiry from JWT_EXPIRATION or 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 });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
};
Enter fullscreen mode Exit fullscreen mode
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 }));
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode
  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"));
  }, [])
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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).
  • localStorage is 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)