A zero-knowledge password manager and credential vault. The server never sees a plaintext password, vault item, or encryption key — all encryption happens client-side before anything touches the network.
Built as a full-stack TypeScript monorepo with a React web app, Express + PostgreSQL backend, and an optional Electron desktop wrapper — all sharing the same cryptographic core.
- Features
- Tech Stack
- Zero-Knowledge Architecture
- Repository Structure
- Prerequisites
- Local Development Setup
- Environment Variables Reference
- npm Scripts Reference
- Building for Production
- API Endpoints
- Database Schema
- Security Model
- Extending AxisVault
- Troubleshooting
- Zero-knowledge encryption — AES-256-GCM vault encryption, Argon2id key derivation; the server stores only ciphertext and never sees plaintext data
- Login vault — store usernames, passwords, URIs, TOTP seeds, and custom fields per entry
- API key vault — store API keys with header name, auth scheme, service URL, and expiry date tracking
- Organized sections — Logins and API Keys displayed in clearly labelled, separate sections in the UI
- Password generator — configurable length and character sets with a live strength meter
- Auto-lock — configurable inactivity timeout; vault key is wiped from memory on lock
- Master password rotation — re-wraps the vault key under the new password without re-encrypting all items
- Encrypted export — full vault backup including the wrapped vault key and KDF params for offline recovery
- Dark / light theme — warm amber-and-teal dark theme ("Forge") + warm cream light theme ("Parchment") with futuristic CSS animations; persisted across sessions
- Desktop app — same React build loaded in Electron for a native-feeling experience on Windows, macOS, and Linux
| Technology | Role |
|---|---|
| React 18 | UI framework |
| TypeScript 5 | Type safety across all packages |
| Vite 5 | Dev server + bundler (ES modules for web, IIFE bundle for Electron) |
| React Router DOM v6 | Client-side routing with auth/session route guards |
| Zustand | Lightweight state management (authStore + vaultStore) |
| Lucide React | Icon library |
| CSS Custom Properties | Design token system — no Tailwind, no CSS-in-JS |
| JetBrains Mono | Monospace font for data labels and code-like UI |
WebCrypto API (crypto.subtle) |
Native browser AES-256-GCM encryption and decryption |
| argon2-browser | WASM Argon2id for client-side key derivation |
| Technology | Role |
|---|---|
| Node.js 20+ | Server runtime |
| Express 4 | HTTP framework |
| PostgreSQL 14+ | Primary database (stores ciphertext only) |
| pg | PostgreSQL client (raw SQL, no ORM) |
| node-argon2 | Server-side re-hashing of the client auth hash before storage |
| jsonwebtoken | JWT access + refresh token issuance and validation |
| Zod | Request body and query parameter validation |
| Pino | Structured JSON logging with sensitive-field redaction |
| Helmet | Security response headers |
| express-rate-limit | Rate limiting on authentication routes |
| CORS | Cross-origin request handling |
| Technology | Role |
|---|---|
| TypeScript | Shared type definitions used by all packages |
| WebCrypto API | Portable crypto.subtle wrappers (runs in browser and Electron renderer) |
| argon2-browser | WASM Argon2id — runs only on the client, never on the server |
| Technology | Role |
|---|---|
| Electron | Native app shell |
| Electron Builder | Cross-platform packaging and installer generation |
| TypeScript | Main process and preload (IPC bridge) scripts |
Master Password (user types this — never transmitted, never stored anywhere)
│
├─ Argon2id(pw, saltA, context="axisvault-auth-v1") → AuthHash
│ sent to server at login
│ → server re-hashes with its own Argon2id pass → stored in DB
│
└─ Argon2id(pw, saltB, context="axisvault-enc-v1") → MasterKey
│ (lives in memory only,
│ never transmitted)
└─ unwraps → VaultKey
│ (random AES-256 key, generated once at signup;
│ stored server-side only in its wrapped/encrypted form)
│
└─ AES-256-GCM encrypts every vault item
before it is sent to the server
Two independent Argon2id derivations from the same password:
saltAandsaltBare different random salts generated at signup; different context strings also prevent any relationship between the two outputsAuthHash(what the server uses to verify identity) is cryptographically unrelated toMasterKey(what decrypts the vault) — a full database dump cannot be used to decrypt vault data- The server re-hashes
AuthHashwith its own Argon2id pass before storage, so a database leak still requires cracking a high-cost hash, not simply replaying a stolen value
VaultKey lifecycle:
- Generated randomly at signup (never derived from the password)
- Wrapped (AES-GCM encrypted) under
MasterKeyand stored server-side in that form - On login: client downloads the wrapped key, derives
MasterKey, unwrapsVaultKeyin memory MasterKeyis wiped from memory immediately after the unwrap- Changing master password re-wraps the same
VaultKeyunder a newMasterKey— vault items do not need to be re-encrypted
In-memory security:
- On auto-lock or manual lock:
VaultKeyreference is dropped and the decrypted item cache is cleared — only ciphertext remains in memory after locking - The vault key is never written to
localStorage,sessionStorage, or disk - The session KDF params and wrapped vault key (both safe to cache) are stored in
sessionStorageto allow re-unlock without a server round-trip after inactivity lock
axisvault/
├── packages/
│ ├── shared/ # Crypto core + shared TypeScript types
│ │ └── src/
│ │ ├── crypto.ts # AES-256-GCM, Argon2id wrappers, vault key rotation
│ │ ├── types.ts # VaultItem types, API request/response contracts
│ │ └── index.ts # Public exports
│ │
│ ├── server/ # Express + PostgreSQL API server
│ │ ├── .env.example # Environment variable template (copy to .env)
│ │ └── src/
│ │ ├── app.ts # Express setup: middleware, routes, error handler
│ │ ├── config.ts # Environment variable loading and validation
│ │ ├── logger.ts # Pino logger with field redaction
│ │ ├── auth/
│ │ │ └── tokens.ts # JWT issuance and verification
│ │ ├── routes/
│ │ │ ├── auth.ts # /api/auth/* HTTP handlers
│ │ │ ├── vault.ts # /api/vault/* HTTP handlers
│ │ │ └── schemas.ts # Zod validation schemas for all routes
│ │ ├── services/
│ │ │ ├── authService.ts # User creation, login, password rotation, token validation
│ │ │ └── vaultService.ts # Item/folder sync logic (push/pull/export)
│ │ ├── middleware/
│ │ │ ├── requireAuth.ts # JWT Bearer token verification middleware
│ │ │ └── errorHandler.ts # Centralized error formatting
│ │ └── db/
│ │ ├── pool.ts # PostgreSQL connection pool
│ │ ├── migrate.ts # Migration runner (applies *.sql files in order)
│ │ └── migrations/
│ │ └── 001_init.sql # Full schema: users, tokens, vault, sync, audit
│ │
│ ├── web/ # React SPA — the vault UI
│ │ ├── index.html
│ │ ├── vite.config.ts # Vite config (dual web/Electron build modes)
│ │ ├── .env.electron # Electron-specific Vite env (tracked — no secrets)
│ │ └── src/
│ │ ├── main.tsx # React entry point + pre-mount theme application
│ │ ├── App.tsx # BrowserRouter/HashRouter + route definitions
│ │ ├── pages/
│ │ │ ├── LoginPage.tsx
│ │ │ ├── SignupPage.tsx
│ │ │ ├── VaultPage.tsx # Main vault UI (item list + detail panel)
│ │ │ ├── LockScreen.tsx # Auto-lock + session expiry screen
│ │ │ ├── SettingsPage.tsx
│ │ │ └── ExportPage.tsx # Encrypted vault export
│ │ ├── components/
│ │ │ ├── ItemList.tsx # Sidebar list with Login / API Key sections
│ │ │ ├── ItemDetail.tsx # Create/view/edit panel for all item types
│ │ │ ├── Sidebar.tsx # Nav + folder list + theme toggle
│ │ │ ├── RouteGuards.tsx # RequireUnlockedVault, RequireAuthenticated, RedirectIfAuthenticated
│ │ │ ├── PasswordGenerator.tsx
│ │ │ ├── StrengthMeter.tsx
│ │ │ ├── VaultDialIcon.tsx
│ │ │ └── ui.tsx # Shared primitive components (Button, Input, Field, Card)
│ │ ├── store/
│ │ │ ├── authStore.ts # Zustand: tokens, vault key, lock state, auto-lock
│ │ │ └── vaultStore.ts # Zustand: decrypted items, folders, sync
│ │ ├── hooks/
│ │ │ ├── useAutoLock.ts # Inactivity timer — locks vault after configured timeout
│ │ │ └── useTheme.ts # Dark/light theme with localStorage persistence
│ │ ├── lib/
│ │ │ ├── apiClient.ts # Fetch wrapper with JWT refresh + session expiry handling
│ │ │ └── electronBridge.d.ts # Type definitions for Electron IPC bridge
│ │ └── styles/
│ │ └── tokens.css # All CSS custom properties (design tokens + animations)
│ │
│ └── desktop/ # Electron shell
│ ├── tsconfig.main.json # TypeScript config for main + preload (CommonJS)
│ ├── build/
│ │ ├── icon.ico # Windows app icon
│ │ ├── icon.png # Linux app icon
│ │ ├── icon.icns # macOS app icon (not in repo — generate with script)
│ │ └── generate-icns.sh # Script to generate .icns from .png (macOS only)
│ └── src/
│ ├── main/main.ts # Electron main process (BrowserWindow, app lifecycle)
│ └── preload/preload.ts # Preload script — contextBridge IPC exposure
│
├── scripts/
│ └── start-server.sh # Helper to daemonize the server on Linux/macOS
├── package.json # Monorepo root: npm workspaces + shared scripts
└── package-lock.json
| Requirement | Minimum Version | Check |
|---|---|---|
| Node.js | 20.0.0 | node -v |
| npm | 10.0.0 | npm -v (included with Node 20) |
| PostgreSQL | 14 | psql --version |
| Git | Any recent version | git --version |
Installing PostgreSQL:
- Windows: Official installer or
scoop install postgresql - macOS:
brew install postgresql@16 && brew services start postgresql@16 - Ubuntu/Debian:
sudo apt install postgresql && sudo systemctl start postgresql
After installation, ensure psql and createdb are available on your PATH. On Windows you may need to add PostgreSQL's bin/ directory to System Environment Variables.
git clone <repository-url>
cd axisvaultnpm installThis installs dependencies for every workspace (packages/shared, packages/server, packages/web, packages/desktop) in a single command via npm workspaces.
@axisvault/shared is consumed as compiled JavaScript by the other packages — its dist/ folder must exist before anything else can start.
npm run build:sharedOr run everything at once with the convenience script:
npm run setupnpm run setup runs npm install, builds @axisvault/shared, and builds @axisvault/server — safe to run multiple times.
createdb axisvaultOr using psql:
psql -U postgres -c "CREATE DATABASE axisvault;"If you want a dedicated database user (recommended):
psql -U postgres -c "CREATE USER axisvault WITH PASSWORD 'yourpassword';"
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE axisvault TO axisvault;"cp packages/server/.env.example packages/server/.envOpen packages/server/.env and fill in the required values. At minimum:
DATABASE_URL=postgresql://axisvault:yourpassword@localhost:5432/axisvault
JWT_ACCESS_SECRET=<generate-below>
JWT_REFRESH_SECRET=<generate-below>Generate cryptographically secure JWT secrets:
node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"Run this command twice and use the two outputs as JWT_ACCESS_SECRET and JWT_REFRESH_SECRET. They must be different values.
See the full Environment Variables Reference for all available options.
npm run migrateThis builds the server and applies all SQL migration files in packages/server/src/db/migrations/ in order. Safe to re-run — migrations use IF NOT EXISTS guards.
npm run dev:serverThe API server starts at http://localhost:4000. This command watches for changes in both packages/shared and packages/server and reloads automatically.
Verify it's running:
curl http://localhost:4000/api/health
# or open http://localhost:4000 in a browsernpm run dev:webThe React app starts at http://localhost:5173. Vite proxies all /api requests to http://localhost:4000, so there are no CORS issues in development.
Open http://localhost:5173, create an account, and start using the vault.
Requires the backend (step 7) to be running. The Electron dev build points at
http://localhost:5173, so the Vite dev server (step 8) must also be running.
cd packages/desktop
npm run devThis compiles the Electron main process and launches the app pointed at http://localhost:5173.
All variables live in packages/server/.env. Start from the provided template:
cp packages/server/.env.example packages/server/.env| Variable | Default | Required | Description |
|---|---|---|---|
NODE_ENV |
development |
No | development or production. Controls log format and error verbosity. |
PORT |
4000 |
No | Port the Express server listens on. |
DATABASE_URL |
— | Yes | Full PostgreSQL connection string. Format: postgresql://user:password@host:port/dbname |
DB_SSL |
false |
No | Set true when connecting to a managed PostgreSQL service (AWS RDS, Supabase, Railway, etc.) that requires SSL. |
DB_POOL_MAX |
10 |
No | Maximum connections in the PostgreSQL pool. |
JWT_ACCESS_SECRET |
— | Yes | Secret for signing access tokens. Min 32 characters. Must differ from JWT_REFRESH_SECRET. |
JWT_REFRESH_SECRET |
— | Yes | Secret for signing refresh tokens. Min 32 characters. |
JWT_ACCESS_TTL |
15m |
No | Access token lifetime. Short values (15 minutes) are recommended — the client refreshes transparently. |
JWT_REFRESH_TTL_DAYS |
30 |
No | Refresh token lifetime in days. |
CORS_ORIGIN |
http://localhost:5173 |
No | Allowed CORS origin. Set to your production web app domain in production. |
ALLOW_NULL_ORIGIN |
false |
No | Set true to accept requests from Electron (Electron's file:// pages send Origin: null). Do not enable on a shared server without understanding the security implications. |
SERVER_ARGON2_MEMORY_KIB |
19456 |
No | Argon2id memory cost for server-side auth hash re-hashing (~19 MiB). Tune to your server hardware. |
SERVER_ARGON2_TIME_COST |
2 |
No | Argon2id iteration count for server-side hashing. |
SERVER_ARGON2_PARALLELISM |
1 |
No | Argon2id parallelism for server-side hashing. |
RATE_LIMIT_AUTH_MAX |
10 |
No | Max requests to auth endpoints per IP per window. |
RATE_LIMIT_AUTH_WINDOW_MS |
900000 |
No | Rate limit window in milliseconds (default: 15 minutes). |
The web app reads a single Vite environment variable at build time only. In development the API is always proxied and this variable is unused.
| Variable | Default | Description |
|---|---|---|
VITE_API_BASE_URL |
(empty — uses relative /api) |
Full API origin for production and Electron builds. Example: https://api.yourdomain.com |
This variable is pre-set for the Electron build in packages/web/.env.electron (intentionally tracked in git — it contains no secrets).
Run all scripts from the repository root unless otherwise noted.
| Script | Description |
|---|---|
npm run setup |
Install deps + build shared + build server. Run once after cloning. |
npm run dev:server |
Start backend in watch mode (auto-rebuilds shared + server on change). API at http://localhost:4000 |
npm run dev:web |
Start Vite dev server (auto-rebuilds shared on change). App at http://localhost:5173 |
npm run dev:shared |
Watch and rebuild only packages/shared |
npm run dev:desktop |
Launch Electron in dev mode (requires backend + Vite dev server running) |
npm run build:shared |
Compile packages/shared → packages/shared/dist/ |
npm run build:server |
Build shared + compile packages/server → packages/server/dist/ |
npm run build:web |
Build shared + Vite production bundle → packages/web/dist/ |
npm run build:desktop |
Build shared + Electron renderer + main process |
npm run migrate |
Build server + apply all pending SQL migrations |
npm run test:shared |
Run Jest tests for the shared crypto package |
npm run build:webOutput: packages/web/dist/
Deploy the contents of packages/web/dist/ to any static host (Vercel, Netlify, AWS S3 + CloudFront, nginx, etc.). All routes must serve index.html for client-side routing to work.
Example nginx configuration:
server {
listen 443 ssl;
server_name yourdomain.com;
root /var/www/axisvault;
index index.html;
# Serve SPA — all unknown paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
location /api/ {
proxy_pass http://localhost:4000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Running the production server:
npm run build:server
# Set NODE_ENV=production in packages/server/.env
node packages/server/dist/index.jsUsing PM2 for process management:
npm install -g pm2
pm2 start packages/server/dist/index.js --name axisvault-api
pm2 save && pm2 startupcd packages/desktop
npm run build # Builds renderer (Electron Vite mode) + main process
npm run package:win # Produces NSIS installer in packages/desktop/release/Code signing: Without a certificate, Windows SmartScreen will show an "unidentified publisher" warning. To sign: set
CSC_LINK(path to your.pfxfile) andCSC_KEY_PASSWORDbefore packaging.
# Generate the .icns icon (macOS only — requires iconutil)
bash packages/desktop/build/generate-icns.sh
cd packages/desktop
npm run build
npm run package:mac # Produces a .dmg in packages/desktop/release/Notarization: Required for distribution outside the Mac App Store. Set
APPLE_ID,APPLE_APP_SPECIFIC_PASSWORD, andAPPLE_TEAM_ID, then configure thenotarizehook inelectron-builder.yml.
cd packages/desktop
npm run build
npm run package:linux # Produces AppImage in packages/desktop/release/Important: npm run build inside packages/desktop overwrites packages/web/dist/ with an Electron-specific bundle (IIFE format, HashRouter, relative asset paths). If you need both a web deployment and a desktop package, rebuild the web bundle afterward:
npm run build:web # Restores packages/web/dist/ to the standard web bundleWhy the Electron build is different from the web build:
| Aspect | Web build | Electron build |
|---|---|---|
| Router | BrowserRouter |
HashRouter (no server to resolve paths under file://) |
| Bundle format | ES modules | IIFE (Chromium blocks ES module import over file://) |
| Asset paths | Absolute (/assets/…) |
Relative (./assets/…) |
| API base URL | Relative /api (proxied by nginx) |
Full URL from VITE_API_BASE_URL |
All endpoints are prefixed with /api. Auth routes are rate-limited to 10 requests per 15 minutes per IP by default.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/auth/signup |
None | Create an account. Body: { email, authHash, kdfParams, wrappedVaultKey } |
GET |
/api/auth/kdf-params |
None | Fetch KDF params for a given email (?email=). Required before login to derive the correct authHash. |
POST |
/api/auth/login |
None | Authenticate. Body: { email, authHash }. Returns access token, refresh token, KDF params, and wrapped vault key. |
POST |
/api/auth/refresh |
None | Rotate refresh token. Body: { refreshToken }. Returns new token pair. Reuse of a rotated token revokes the entire family. |
POST |
/api/auth/logout |
Bearer | Revoke the current refresh token. Body: { refreshToken } |
PUT |
/api/auth/change-password |
Bearer | Rotate master password. Accepts current auth hash + new auth hash + new wrapped vault key. |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/vault/pull |
Bearer | Fetch all encrypted items and folders. Supports incremental sync via syncToken. |
POST |
/api/vault/push |
Bearer | Push encrypted item and folder changes. Uses baseSyncToken for optimistic concurrency conflict detection. |
GET |
/api/vault/export |
Bearer | Export the complete encrypted vault: items, folders, wrapped vault key, and KDF params. |
All vault payloads are AES-256-GCM ciphertext. The server cannot read vault contents.
Managed by raw SQL files in packages/server/src/db/migrations/. The migration runner tracks applied files and only runs new ones.
| Table | Description |
|---|---|
users |
Accounts: email (CITEXT), server-hashed auth hash (Argon2id), KDF params (JSONB), wrapped vault key, account lock state |
refresh_tokens |
Active refresh tokens: hashed token value, device label, IP, user-agent, expiry, revoked flag |
folders |
Encrypted folder hierarchy: payload is AES-256-GCM ciphertext of { name } |
vault_items |
Encrypted credentials: payload is AES-256-GCM ciphertext of { name, data, favorite } |
sync_clocks |
Per-user monotonic counter used for incremental sync and conflict detection |
audit_log |
Security event log: login attempts, password changes, exports — vault contents are never logged |
The payload columns in folders and vault_items are opaque binary blobs to the server — it stores, retrieves, and syncs them without ever decoding them.
| Data | Form stored |
|---|---|
| Email address | Plaintext (required for login lookup) |
| Auth hash | Argon2id hash (server re-hashes the client-derived value) |
| KDF parameters (salts + cost factors) | Plaintext JSONB — not secret; needed by the client before login |
| Wrapped vault key | AES-GCM ciphertext (encrypted under MasterKey which the server never sees) |
| Vault items | AES-256-GCM ciphertext |
| Folder names | AES-256-GCM ciphertext |
| Refresh tokens | SHA-256 hash |
- The master password (never transmitted)
MasterKey(derived client-side, never transmitted)VaultKeyin plaintext (only its wrapped, encrypted form is stored)- Any plaintext vault field: passwords, usernames, URLs, API keys, notes, TOTP seeds
- Access tokens: short-lived JWTs (default 15 minutes). Verified from the JWT signature alone — no database lookup on each request.
- Refresh tokens: long-lived (default 30 days), stored as SHA-256 hashes. Each use issues a new token pair and marks the old one used. Presenting a previously rotated token triggers revocation of the entire token family (refresh token rotation with reuse detection).
- Auto-lock: client-side only. After the configured inactivity period, the
VaultKeyis dropped from memory and the decrypted item cache is cleared. The server is not involved. Re-unlock re-derives theVaultKeyfrom the cached wrapped key insessionStorage— no server round-trip needed. - Session expiry: when the server rejects a token refresh (expired or revoked), the client clears all local auth state and navigates to the login page.
Always deploy behind HTTPS in production. Without TLS, the authHash can be captured in transit and replayed for login (though it still cannot decrypt the vault). Use nginx, Caddy, or Cloudflare Tunnel to terminate TLS in front of the Node server.
- Add a new interface in
packages/shared/src/types.tsand add it to theVaultItemDataandVaultItemTypeunions - Add a form and type picker card in
packages/web/src/components/ItemDetail.tsx - Add a section header in
packages/web/src/components/ItemList.tsxif the type warrants its own section - No server changes needed — the payload is encrypted and opaque to the server
- Never import
@axisvault/shared's crypto module from server code. The server must never have access to key material or plaintext vault data — this is the core of the zero-knowledge model. - Never log request bodies on auth or vault routes. Pino's field redaction is defense-in-depth, not a substitute for not logging sensitive paths.
- New private vault fields belong inside the encrypted
payloadblob, not as new plaintext database columns. - Do not store the vault key, master key, or any plaintext vault data in
localStorage. OnlysessionStorageis acceptable for the wrapped vault key and KDF params (both are already available server-side and contain no secret key material on their own).
@axisvault/shared is consumed as compiled JavaScript — dist/ is not tracked in the repo. Run:
npm run setup
# or just:
npm run build:sharedThe server TypeScript must be compiled before the migration runner can execute. Run:
npm run migrateThis script builds the server first, then runs migrations.
The backend isn't running. Start it with:
npm run dev:serverConfirm it's listening: curl http://localhost:4000
The credentials in DATABASE_URL don't match your PostgreSQL setup. Create a dedicated database user:
psql -U postgres -c "CREATE USER axisvault WITH PASSWORD 'yourpassword';"
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE axisvault TO axisvault;"Then update DATABASE_URL in packages/server/.env:
DATABASE_URL=postgresql://axisvault:yourpassword@localhost:5432/axisvault
The unlock material (kdfParams and wrappedVaultKey) is stored in sessionStorage, which browsers clear when the tab or window is closed. On reopen, the app correctly prompts for the master password — it cannot re-derive the vault key without it. This is expected behavior.
The Electron dev build loads http://localhost:5173. Both the Vite dev server and the backend must be running:
# Terminal 1
npm run dev:server
# Terminal 2
npm run dev:web
# Terminal 3
cd packages/desktop && npm run devpackages/desktop/build/icon.icns is not in the repo (iconutil is macOS-only). Generate it on a Mac:
bash packages/desktop/build/generate-icns.shnpm run build inside packages/desktop overwrites packages/web/dist/ with the Electron bundle. To restore the standard web bundle:
npm run build:web