How it works in three steps
Sign-in with the company account means the app keeps no password list of its own. Employees type the same username and password as for their computer, and the app asks the company directory, Active Directory, about them.
- Finding the user
The app connects to the domain controller with a service account and looks up the person by username, UPN or email. It gets their full identifier in the directory and the list of groups.
- Checking the password
The app tries to connect to the controller as that user, with the password they typed. If the controller accepts the connection, the password is correct. The password itself is never stored.
- Granting permissions
Based on the groups, the app decides whether the person may enter at all, which modules they can use and with what role. It does this at every sign-in, so a change in the directory takes effect at the next visit.
What the IT department has to prepare
Permissions from groups instead of granting them by hand
The simplest model that scales well has three layers: an entry group (who can sign in at all), module groups (where you can go) and role groups (how deep you can see inside). Module and role are two independent dimensions: someone can have access to a report without the right to see internal notes.
| AD group | What it gives |
|---|---|
| APP-Users | Entry to the app. Anyone not in this group can't sign in at all. |
| APP-Module-Parking | Access to one module, here: parking bookings. |
| APP-Role-Analyst | A role in the modules: for example details an ordinary user doesn't see. |
| APP-Role-Admin | Full administrative rights. With several roles, the higher one wins. |
When someone has access to no module, show them a clear message after sign-in instead of a sign-in error. A sign-in error sends people off to reset a password that is actually fine.
Pitfalls that only show up in practice
- Nested groups
The memberOf attribute returns direct memberships only. If someone is in a group through another group, the app won't see it unless it asks specifically (AD has a special matching rule for that). The simplest approach is to agree that the app's groups contain people directly.
- Username, UPN or email
One day someone types jan.kowalski, another day jan.kowalski@company.com. Always create the app account under one identifier (for example sAMAccountName), or one person will end up with two accounts.
- An empty password
Some servers treat a connection with an empty password as anonymous and report success. The app must reject an empty username or password before it asks the controller at all.
- Attempt limits
A few failed attempts should lock sign-in in the app for a few minutes. Otherwise a password-guessing script will lock the employee's domain account across the whole company.
- The controller's certificate
Encryption without checking the certificate protects against eavesdropping, but not against a fake server. Point the app at the authority's certificate file and turn verification on.
An example in Python
A sketch of the logic with the ldap3 library, which is pure Python and installs without extra programs, also on Windows. A real app adds timeouts, a sign-in log and attempt limits.
import os, ssl
from ldap3 import Server, Connection, Tls, SUBTREE
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
TLS = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file="C:/certs/company-ca.pem")
SERVER = Server("dc01.company.local", port=636, use_ssl=True, tls=TLS)
BASE = "DC=company,DC=local"
ACCOUNT = "CN=svc-app,OU=Service,DC=company,DC=local"
def sign_in(login, password):
if not login or not password: # empty username or password: never ask the controller
return None
f = escape_filter_chars(login)
with Connection(SERVER, ACCOUNT, os.environ["LDAP_SVC_PASSWORD"], auto_bind=True) as svc:
svc.search(BASE, f"(|(sAMAccountName={f})(userPrincipalName={f})(mail={f}))",
SUBTREE, attributes=["sAMAccountName", "displayName", "memberOf"])
if len(svc.entries) != 1:
return None
user = svc.entries[0]
try: # the controller checks the password: try to connect as this user
Connection(SERVER, user.entry_dn, password, auto_bind=True).unbind()
except LDAPException:
return None
return {"login": user.sAMAccountName.value, "name": user.displayName.value,
"groups": list(user.memberOf.values)}In Django such code goes into a custom authentication backend (the AUTHENTICATION_BACKENDS setting). At every sign-in the backend creates or updates the Django account and saves the groups as roles.
Terms from this guide
- Active Directory
The directory of users, groups and computers in a company's Windows network. An internal app can take sign-in and permissions from it.
- LDAP and LDAPS
The protocol an app uses to talk to the user directory: it looks up accounts and checks passwords. LDAPS is its encrypted version, usually on port 636.
Questions and answers
Does the app store employees' passwords?
No. The password goes only to the domain controller at the moment of sign-in. The app records the result: who signed in and when.
The company has Microsoft 365, not its own AD server. What then?
Then sign-in is usually done through Microsoft Entra ID and the OpenID Connect protocol, not LDAP. The principle is the same: Microsoft checks the password, and the app gets information about the user and their groups.
What happens when the domain controller is down?
Nobody new can sign in, and those signed in work until their session ends. That's why at least two controllers are configured.
Can sign-in work without encryption?
Technically yes, but then domain passwords travel across the network in plain text. Microsoft has recommended signing and encrypting LDAP connections for years, so it isn't an option for production.
Sources and documentation
- ldap3: documentation of the LDAP library for Pythonldap3.readthedocs.io
- Django: customising authenticationdocs.djangoproject.com
- Microsoft: how to enable LDAP signing in Windows Serverlearn.microsoft.com
- Microsoft ADV190023: LDAP channel binding and signingmsrc.microsoft.com