A self-hosted, encrypted password manager with per-user vaults, admin controls, and JWT authentication. Runs entirely in Docker.
- Encrypted storage β passwords at rest are encrypted with AES-256-GCM
- Per-user vaults β each user sees only their own saved entries
- JWT authentication β stateless, 8-hour sessions with automatic inactivity logout after 2 minutes
- Password generator β cryptographically random 20-character passwords with strength meter
- Forgot password β users can reset their own password from the login page (no email required; requires knowing the username)
- Admin panel β admins can manage all registered users:
- View all accounts with join date
- Delete any user (cascades to their vault entries)
- Force a user to change their password on next login
- Download a full SQL backup of the database
- Restore a previously downloaded SQL backup
- Forced password change β default admin and any admin-reset accounts must set a new password before accessing the app
- Emergency admin reset β
reset-admin-password.shscript to recover the admin account from the host when the UI is inaccessible - Rate limiting β brute-force protection on all auth and write endpoints
- Security headers β CSP, X-Frame-Options, X-Content-Type-Options via Nginx + Helmet
flowchart TD
Browser(["π Browser"])
subgraph host["Host Machine"]
subgraph frontend["frontend-net"]
Nginx["nginx:1.27-alpine\nβββββββββββββββββ\nβ’ Serves static files HTML/CSS/JS\nβ’ Proxies /api/* β backend:3000\nβ’ Security headers CSP, X-Frame-Options"]
end
subgraph backend_net["backend-net (internal β no internet access)"]
Backend["Node.js 20 / Express 4\nβββββββββββββββββ\nβ’ JWT auth middleware\nβ’ bcrypt hashing 12 rounds\nβ’ AES-256-GCM vault encryption\nβ’ Rate limiting auth:10/15min write:20/15min\nβ’ Auto-seeds default admin on first start"]
DB[("MariaDB LTS\nβββββββββββββββββ\nβ’ app_users β login credentials\nβ’ users β encrypted vault entries\nβ’ Volume: mysql_data persistent")]
end
end
Browser -->|"HTTP :8080"| Nginx
Nginx -->|"/api/* β :3000"| Backend
Backend -->|"SQL queries"| DB
backend-netis markedinternal: trueβ the backend and database have no direct internet access. Only Nginx is exposed to the host.
| Network | Members | Internet Access |
|---|---|---|
frontend-net |
nginx | Yes (via host port 8080) |
backend-net |
nginx, backend, mariadb | No (internal) |
Browser
β
β HTTP :8080
βΌ
Nginx (nginx:1.27-alpine)
β
βββ GET / β serve login.html (static)
βββ GET /index.html β serve index.html (static)
βββ GET /style.css β serve style.css (static)
β
βββ /api/* βββββββββββΊ Express Backend (:3000)
β
βββ POST /api/auth/login
βββ POST /api/auth/register
βββ POST /api/auth/reset-password
βββ POST /api/auth/change-password [JWT required]
βββ GET /api/passwords [JWT required]
βββ POST /api/passwords [JWT required]
βββ DELETE /api/passwords/:id [JWT required]
βββ GET /api/admin/users [JWT + admin]
βββ DELETE /api/admin/users/:id [JWT + admin]
βββ POST /api/admin/users/:id/reset-password [JWT + admin]
βββ GET /api/admin/backup [JWT + admin]
βββ POST /api/admin/restore [JWT + admin]
app_users users
βββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
id INT UNSIGNED PK id INT UNSIGNED PK
username VARCHAR(64) UNIQUE user_id INT UNSIGNED FK βββΊ app_users.id
is_admin TINYINT(1) website VARCHAR(255)
must_change_password TINYINT(1) username VARCHAR(64)
password_hash VARCHAR(255) password VARCHAR(255) β AES-256-GCM
created_at TIMESTAMP created_at TIMESTAMP
ON DELETE CASCADE
ββββββββββββ βββββββββββ ββββββββββββββββββββ
β Browser β β Backend β β Database β
ββββββ¬ββββββ ββββββ¬βββββ ββββββββββ¬ββββββββββ
β β β
β POST /api/auth/login β β
β { username, password } β β
ββββββββββββββββββββββββββββββΊβ β
β β SELECT id, is_admin, β
β β must_change_password, β
β β password_hash β
β β WHERE username = ? β
β βββββββββββββββββββββββββββββΊβ
β ββββββββββββββββββββββββββββββ
β β β
β β bcrypt.compare() β
β β (always runs β timing β
β β attack protection) β
β β β
β 200 { token, isAdmin, β β
β mustChangePassword } β β
βββββββββββββββββββββββββββββββ β
β β β
β mustChangePassword=true? β β
β β redirect: change-password.html β
β β β
β mustChangePassword=false? β β
β β redirect: index.html β β
β β β
β Subsequent requests: β β
β Authorization: Bearer JWT β β
ββββββββββββββββββββββββββββββΊβ β
β β jwt.verify(token, β
β β { algorithms: ['HS256']}β
β β β req.user = { id, β
β β username, isAdmin } β
- Docker Engine 24+
- Docker Compose v2
Linux (Debian / Ubuntu)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # log out and back in after thismacOS Download and install Docker Desktop. Docker Compose is bundled.
Windows Download and install Docker Desktop. Enable WSL 2 backend when prompted. Docker Compose is bundled.
Verify the installation:
docker --version # Docker version 24.x or later
docker compose version # Docker Compose version v2.x or latergit clone <repo-url>
cd passWord
cp .env.example .envEdit .env and set strong values for every variable (see Configuration).
docker compose up -dOn first start the backend will log:
[seed] Default admin created β username: admin, password: password (must change on first login)
Navigate to http://localhost:8080
You will be redirected to the login page. Log in with:
| Field | Value |
|---|---|
| Username | admin |
| Password | password |
You will be immediately redirected to the Change Password page. Set a strong password (12β20 characters, must include uppercase, lowercase, number, and special character) before you can access the app.
Copy .env.example to .env and fill in all values before starting.
| Variable | Description | Example |
|---|---|---|
MYSQL_ROOT_PASSWORD |
MariaDB root password (also used by the restore route) | ch@ngeMe_r00t! |
MYSQL_DATABASE |
Database name | password_app |
MYSQL_USER |
Application DB user | appuser |
MYSQL_PASSWORD |
Application DB password | ch@ngeMe_app! |
ENCRYPTION_KEY |
64-hex-char AES-256-GCM key for vault entries | openssl rand -hex 32 |
JWT_SECRET |
64-hex-char HMAC-SHA256 signing secret | openssl rand -hex 32 |
NODE_ENV |
Node environment | production |
Generate secrets:
openssl rand -hex 32 # for ENCRYPTION_KEY
openssl rand -hex 32 # for JWT_SECRETpassWord/
βββ frontend/ # Static files served by Nginx
β βββ index.html # Main app UI (vault + admin panel)
β βββ login.html # Login / Register page
β βββ change-password.html # Forced password change page
β βββ forgot-password.html # Self-service password reset page
β βββ app.js # Main app logic (CRUD, password generation, admin panel)
β βββ login.js # Login / register logic
β βββ change-password.js # Password change logic
β βββ forgot-password.js # Password reset logic
β βββ style.css # Dracula dark theme (Bootstrap 5 overrides)
β
βββ backend/
β βββ src/
β β βββ server.js # Express app, middleware, routes, admin seed
β β βββ db.js # MariaDB connection pool
β β βββ crypto.js # AES-256-GCM encrypt/decrypt
β β βββ middleware/
β β β βββ authenticate.js # JWT verification β req.user
β β β βββ requireAdmin.js # Admin-only guard
β β βββ routes/
β β βββ auth.js # login, register, change-password, reset-password
β β βββ admin.js # user management, backup, restore
β β βββ passwords.js # CRUD for vault entries
β βββ Dockerfile # Multi-stage build (node:20-alpine)
β βββ package.json
β
βββ mysql/
β βββ init/
β βββ 01_init.sql # Schema creation (runs once on fresh volume)
β
βββ nginx/
β βββ default.conf # Reverse proxy + security headers
β
βββ reset-admin-password.sh # Emergency CLI script to reset the admin password
βββ docker-compose.yml
βββ .env.example
βββ README.md
The admin panel is visible only to users with is_admin = 1. It replaces the standard vault UI for admin accounts.
Each registered user appears in a table with their ID, username, join date, and two action buttons:
| Button | Action |
|---|---|
| Key icon (yellow) | Sets must_change_password = 1 β user is redirected to the change-password page on their next login |
| Person-X icon (red) | Permanently deletes the user and all their saved vault entries (irreversible) |
Admins cannot delete or reset-password their own account from this panel.
Click Backup to download a full SQL dump (backup-<timestamp>.sql) containing both the app_users and users tables. Rate-limited to 5 downloads per 15 minutes.
Click Chooseβ¦ to select a previously downloaded .sql backup file, then click Restore and confirm. This replays the full SQL dump against the live database β all current users and vault entries are overwritten. Rate-limited to 3 restores per 15 minutes.
Only files generated by this application's backup feature are accepted. The restore route validates the file header before executing anything.
If the admin account password is lost and the UI is inaccessible, use the bundled script from the project root on the Docker host.
Linux / macOS
./reset-admin-password.sh
# or for a different admin username:
./reset-admin-password.sh someadminWindows (PowerShell)
.\reset-admin-password.ps1
# or for a different admin username:
.\reset-admin-password.ps1 someadminThe script:
- Prompts for a new password (same complexity rules as the UI)
- Generates the bcrypt hash inside the running backend container (no host-side dependencies)
- Updates the database and sets
must_change_password = 1
The backend and database services must be running (docker compose up -d).
All /api/passwords and /api/admin endpoints require Authorization: Bearer <token>.
| Method | Path | Body | Response |
|---|---|---|---|
POST |
/api/auth/register |
{ username, password } |
201 or 409 / 422 |
POST |
/api/auth/login |
{ username, password } |
200 { token, username, isAdmin, mustChangePassword } |
POST |
/api/auth/reset-password |
{ username, password } |
200 or 404 / 422 |
POST |
/api/auth/change-password |
{ currentPassword, password } |
200 or 401 / 422 β requires JWT |
| Method | Path | Description |
|---|---|---|
GET |
/api/passwords |
List current user's entries |
POST |
/api/passwords |
Save a new entry |
DELETE |
/api/passwords/:id |
Delete an entry (owner only) |
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/users |
List all registered users |
DELETE |
/api/admin/users/:id |
Delete a user and all their entries |
POST |
/api/admin/users/:id/reset-password |
Force user to change password on next login |
GET |
/api/admin/backup |
Download a full SQL dump (rate-limited: 5 / 15 min) |
POST |
/api/admin/restore |
Restore a SQL backup file (rate-limited: 3 / 15 min) |
- 12β20 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character
| Concern | Mitigation |
|---|---|
| Vault passwords at rest | AES-256-GCM with random IV per entry |
| Login credential storage | bcrypt (cost factor 12) |
| Username enumeration via timing | Constant-time dummy bcrypt compare when user not found |
| JWT algorithm confusion | algorithms: ['HS256'] pinned in jwt.verify |
| Brute force | 10 failed auth attempts per IP per 15 min (skipSuccessfulRequests: true) |
| Session inactivity | Client-side auto-logout after 2 minutes of inactivity |
| Clickjacking | X-Frame-Options: SAMEORIGIN |
| MIME sniffing | X-Content-Type-Options: nosniff |
| XSS via CDN | CSP restricts scripts/styles to self + cdn.jsdelivr.net |
| DB network exposure | backend-net is Docker-internal; MariaDB not reachable from host |
| Container privilege | Backend runs as non-root appuser inside the container |
| Self-deletion by admin | Server rejects DELETE /api/admin/users/<own-id> with 400 |
| Backup restore abuse | Restore endpoint rate-limited (3/15 min); only accepts files with the application's backup header |
| Restore privilege | Restore uses a root DB connection scoped to a single transaction β MYSQL_ROOT_PASSWORD required in .env |
# Stop containers (data preserved)
docker compose down
# Stop and delete all data (wipe the database volume)
docker compose down -vAfter a full wipe, the next docker compose up will re-seed the default admin.