Self-service password change & reset for Active Directory and LDAP — written in Go.
Give users a simple, secure web interface to change or reset their directory passwords — no tickets, no scripts.
- Active Directory & LDAP Support: Works with both AD and OpenLDAP directory services
- Dual Mode Operation: Self-service password change (authenticated) and email-based password reset
- Configurable Password Policies: Enforce minimum length, numbers, symbols, uppercase, lowercase requirements
- Security First: LDAPS support, cryptographic token generation, rate limiting, no password storage
- Real-Time Validation: Client-side validation with immediate feedback on password requirements
- Accessibility Excellence: WCAG 2.2 Level AAA compliant with full keyboard navigation and screen reader support
- Modern UX: Dark mode support, adaptive density, responsive design, optimized for password managers
- Production Ready: Single binary deployment, Docker support, comprehensive logging
- Developer Friendly: Go backend, TypeScript frontend, Tailwind CSS, embedded assets
docker run -d -p 3000:3000 \
-e LDAP_SERVER=ldaps://dc1.example.com:636 \
-e LDAP_IS_AD=true \
-e LDAP_BASE_DN=DC=example,DC=com \
-e LDAP_READONLY_USER=readonly \
-e LDAP_READONLY_PASSWORD=readonly \
-e PASSWORD_RESET_ENABLED=true \
-e SMTP_HOST=smtp.gmail.com \
-e SMTP_PORT=587 \
-e SMTP_USERNAME=notifications@example.com \
-e SMTP_PASSWORD=your_app_password \
-e SMTP_FROM_ADDRESS=noreply@example.com \
-e APP_BASE_URL=https://password.example.com \
ghcr.io/netresearch/ldap-selfservice-password-changerAccess at http://localhost:3000
Prerequisites:
- Go 1.26+
- Bun (no version pinned; CI installs the latest — see installation guide)
# Clone and build
git clone https://github.com/netresearch/ldap-selfservice-password-changer
cd ldap-selfservice-password-changer
bun install
bun run build
# Configure (create .env.local or use flags)
cp .env.local.example .env.local
# Edit .env.local with your directory server details
# Run
./ldap-selfservice-password-changerGopherPass is configured via environment variables or command-line flags. Key settings:
LDAP_SERVER- Directory server URI (ldaps://server:636)LDAP_IS_AD- Set totruefor Active DirectoryLDAP_BASE_DN- Base DN for user searchesLDAP_READONLY_USER- Service account with read accessLDAP_READONLY_PASSWORD- Service account password
MIN_LENGTH- Minimum password length (default: 8)MIN_NUMBERS- Required numeric characters (default: 1)MIN_SYMBOLS- Required special characters (default: 1)MIN_UPPERCASE- Required uppercase letters (default: 1)MIN_LOWERCASE- Required lowercase letters (default: 1)
PASSWORD_RESET_ENABLED- Enable email-based password resetSMTP_HOST/SMTP_PORT- Mail server configurationSMTP_USERNAME/SMTP_PASSWORD- SMTP authenticationSMTP_FROM_ADDRESS- Sender email addressAPP_BASE_URL- Base URL for reset linksRESET_IDENTIFIER_MODE- Identifier the reset form accepts:email,username, orboth(default:email)RESET_TOKEN_EXPIRY_MINUTES- Token validity (default: 15)RESET_RATE_LIMIT_REQUESTS- Max reset requests per window (default: 3)RESET_RATE_LIMIT_WINDOW_MINUTES- Length of that window (default: 60)
Two independent in-memory limiters apply. They are not the same thing and only one of them is configurable.
| Limiter | Limit | Applies to | Configurable |
|---|---|---|---|
| Per IP | 10 requests / 60 minutes, at most 1000 tracked addresses | change-password and request-password-reset |
No — hardcoded in internal/ratelimit/ip_limiter.go |
| Per identifier | RESET_RATE_LIMIT_REQUESTS per RESET_RATE_LIMIT_WINDOW_MINUTES |
request-password-reset only |
Yes |
The per-identifier limiter is keyed twice — once by the identifier as typed and again by the account it resolves to — so requesting a reset for the same account under different spellings does not multiply the allowance.
There is no RATE_LIMIT_* variable prefix. If users report being blocked
without having hit the reset limit, the per-IP limiter is the likely cause,
and changing it requires a code change. Both limiters hold state in memory
only, so a restart clears them and a multi-instance deployment limits per
instance rather than globally.
Every value is optional and the defaults reproduce the stock appearance, so an existing deployment looks unchanged after upgrading.
BRANDING_PRODUCT_NAME- Wordmark next to the logo (default:GopherPass)BRANDING_PAGE_TITLE- Browser tab title of the start page (default: derived from the product name)BRANDING_LOGO_ALT- Alternative text for the logo (default: empty, see below)BRANDING_SHOW_ATTRIBUTION- Show the "Built by Netresearch" footer line (default:true)BRANDING_DIR- Directory whose files replace the built-in assets
Replacing assets. Mount a directory and drop in only the files you want to change:
docker run -v /srv/branding:/branding:ro -e BRANDING_DIR=/branding ...| File | Purpose |
|---|---|
logo.webp |
Logo in the page header |
logo-dark.webp |
Optional dark-mode variant — see below |
favicon.ico, favicon-16x16.png, favicon-32x32.png, apple-touch-icon.png, android-chrome-192x192.png, android-chrome-512x512.png, mstile-150x150.png, safari-pinned-tab.svg |
Browser and home-screen icons |
site.webmanifest |
Installed-app name and colours |
browserconfig.xml |
Windows tile — see the caveat below |
Anything else in the directory aborts startup rather than being ignored, so a typo surfaces immediately. styles.css and js/ are deliberately not replaceable: overriding them would let a deployment silently break the accessibility guarantees the templates rely on. Files must be regular files of at most 2 MiB, and must not be symbolic links pointing outside the directory. Assets you do not supply keep their built-in version.
The directory must be owned by the operator and not writable by anything less privileged — mount it read-only, as above. A file placed there is served publicly and unauthenticated under /static/.
The page's theme-color and msapplication-TileColor are set by <meta> tags in the template and take precedence over the manifest for the document itself; browserconfig.xml is not referenced by any page at all. Overriding site.webmanifest therefore affects the installed-app entry, not the browser chrome, and overriding browserconfig.xml currently has no effect. The manifest's name is also not derived from BRANDING_PRODUCT_NAME — a rebrand has to repeat it in the file.
Kubernetes ConfigMap and Secret volumes work: entries whose name begins with a dot (..data and the timestamped directory kubelet creates) are skipped rather than rejected.
Logo shape and size. The header renders the logo in a square box with object-contain, so a wide wordmark is letterboxed rather than stretched, but it will be small. A roughly square asset around 256×256 gives the best result. Both variants are downloaded on every page load — keep them well under the 2 MiB ceiling.
Dark mode. The app has a light/dark switch, and a dark corporate logo disappears on a dark background. Supply logo-dark.webp and it is shown in dark mode. The switch is class-based, so the variant follows the in-page toggle rather than only the operating-system setting. Note that dark mode requires JavaScript; without it the light logo is always shown. If you remove logo-dark.webp from a running deployment the light logo is served in its place, so the page never shows a broken image — but adding one needs a restart, since the pages are rendered at startup.
Accessibility. The logo is decorative (alt="") as long as the wordmark beside it carries the name; setting BRANDING_LOGO_ALT as well is accepted, but the alt text is ignored while a wordmark is shown, because screen readers would otherwise announce the name twice. If you clear BRANDING_PRODUCT_NAME to show only a logo, you must set BRANDING_LOGO_ALT — otherwise the header consists of a decorative image alone and the brand reaches sighted users only. That combination aborts startup.
The footer link keeps the name GopherPass even on a rebranded deployment: it points at the upstream project, not at your installation.
Hiding the attribution is permitted — the MIT licence does not require it in the UI. The licence and copyright notice in the source still apply.
Unset values fall back to the built-in defaults. A bad header name or value, or a SMTP_FROM_NAME containing control characters, aborts startup unconditionally. A bad address (SMTP_FROM_ADDRESS, EMAIL_REPLY_TO) or a bad template (missing file, parse error, undefined field) aborts startup only when password reset is enabled, since neither is used otherwise. Addresses are checked with the RFC parser, so internal senders such as noreply@localhost are accepted.
SMTP_FROM_NAME- Display name for the sender (encoded per RFC 2047)EMAIL_REPLY_TO-Reply-Toaddress (validated at startup)EMAIL_TEMPLATE_SUBJECT- Subject line, itself a Go templateEMAIL_TEMPLATE_HTML- Path to a Go template for the HTML bodyEMAIL_TEMPLATE_TEXT- Path to a Go template for the plain-text bodySMTP_HEADER_OVERRIDE_*- Raw header injection, one variable per header; the suffix maps_to-(SMTP_HEADER_OVERRIDE_X_HELPDESK_TOPICsetsX-Helpdesk-Topic). Header names go on the wire in canonical (Gonet/textproto) casing — first letter and each letter after a-upper-cased, the rest lower-cased — no matter how the variable was written. Values must not contain control characters: CR, LF, NUL, any other C0 control and DEL are rejected (HTAB is allowed).MIME-Version/Content-Type/Content-Transfer-Encodingcannot be overridden.
Template fields: {{.ResetLink}}, {{.Token}}, {{.BaseURL}}, {{.Recipient}}, {{.ExpiryMinutes}}.
Delivery semantics: To/Cc/Bcc overrides are display-only — the SMTP envelope recipient is always the reset requester, so SMTP_HEADER_OVERRIDE_BCC does not add a delivery target. It is still written as a real, visible Bcc: header line in the message the reset requester receives, so never put an address there that is meant to stay hidden — the override adds no recipient and discloses the address to the user. A cross-domain From-header override creates a From vs envelope MAIL FROM mismatch that can break SPF/DKIM/DMARC alignment and hurt deliverability.
For complete configuration options, run ./ldap-selfservice-password-changer --help
The password reset feature allows users to reset forgotten passwords via secure email-based token verification.
- Email-Based Verification: Secure tokens sent via SMTP (Google Workspace supported)
- Cryptographic Security: 32-byte tokens generated with
crypto/rand - Rate Limiting: 3 reset requests per account per hour by default, plus a separate hardcoded per-IP limit — see Rate Limiting
- Token Expiration: Tokens expire after 15 minutes (configurable)
- Single-Use Tokens: Tokens cannot be reused after password reset
- No User Enumeration: Generic responses prevent account discovery
- Directory Integration: Automatic identifier-to-account lookup (email and/or username, see
RESET_IDENTIFIER_MODE)
- User navigates to
/forgot-passwordand enters their email address or username (perRESET_IDENTIFIER_MODE; the reset link always goes to the account's registered email address) - System looks up user in directory and generates secure token
- Reset email sent with link:
https://your-domain.com/reset-password?token=XXX - User clicks link, enters new password with real-time validation
- Password updated in directory, token marked as used
Security Best Practice: Use dedicated service accounts with minimal permissions.
For Active Directory:
- Read-only account (
LDAP_READONLY_USER): Default Users group permissions - Reset account (
LDAP_RESET_USER, optional): Grant "Reset password" permission on user OU
For OpenLDAP:
# Read-only access
access to dn.subtree="ou=users,dc=example,dc=com"
by dn="cn=readonly,dc=example,dc=com" read
# Password reset access (optional dedicated account)
access to attrs=userPassword
by dn="cn=password-reset,dc=example,dc=com" write
by self write
by * auth
Strongly Recommended for Production: Use ldaps:// instead of ldap:// to encrypt credentials in transit.
# ✅ Recommended: Encrypted connection
LDAP_SERVER=ldaps://dc1.example.com:636
# ⚠️ Not recommended: Unencrypted connection (passwords visible on network)
LDAP_SERVER=ldap://dc1.example.com:389When LDAPS is Required:
- Active Directory password changes (AD protocol requirement)
- Production deployments accessible over untrusted networks
- Compliance requirements (HIPAA, PCI-DSS, SOC 2)
When plain LDAP may be acceptable:
- Internal trusted networks with network-level encryption (VPN, WireGuard)
- Development/testing environments
- Legacy systems where LDAPS deployment is not feasible
GopherPass Behavior:
- Accepts both
ldap://andldaps://connections (non-blocking) - Logs warning at startup when using unencrypted connections
- Provides visibility for security monitoring and compliance auditing
Setting up LDAPS:
- Ensure your LDAP/AD server has valid TLS certificates configured
- Use port 636 for LDAPS (vs port 389 for plain LDAP)
- Test connection:
openssl s_client -connect dc1.example.com:636
Comprehensive documentation is available in the docs/ directory:
- API Reference - JSON-RPC API specification and validation rules
- Development Guide - Setup, workflows, and troubleshooting
- Testing Guide - Testing strategies and recommendations
- Accessibility Guide - WCAG 2.2 AAA compliance and testing procedures
- Architecture - System architecture overview
For a complete overview, see the Documentation Index.
# Copy example environment
cp .env.local.example .env.local
# Edit .env.local with your directory server details
# Start development environment with Mailhog
docker compose --profile dev up
# Application: http://localhost:3000
# Mailhog UI: http://localhost:8025 (view password reset emails)bun install
cp .env.local.example .env.local
# Edit .env.local with your settings
# Run with hot-reload
bun run devGopherPass was originally developed by Netresearch DTT GmbH for Active Directory environments and later expanded to support OpenLDAP. The project emphasizes security, accessibility, and user experience while maintaining simplicity and ease of deployment.
Contributions are welcome! Please:
- Follow Conventional Commits for commit messages
- Use
gofmtandprettierformatting standards - Ensure tests pass and add new tests for features
- Update documentation for significant changes
GopherPass is licensed under the MIT License.
Built with ❤️ by Netresearch DTT GmbH