Zero-knowledge backend for syncing encrypted data across devices.
The server only stores encrypted blobs — it never sees plaintext data.
Browser (tools SPA) This server
┌─────────────────────┐ ┌─────────────────────┐
│ │ │ │
│ Password │ │ authKey (PBKDF2) │
│ ├─ PBKDF2 ──────────── sends ────│ └─ bcrypt hash │
│ └─ AES-256-GCM │ │ │
│ └─ encrypts data │ │ Encrypted blobs │
│ └────────────── PUT ────│ └─ {salt,iv,data}│
│ │ │ │
└─────────────────────┘ └─────────────────────┘
Key principle: The user's password is used for two independent derivations:
- Auth key (PBKDF2 with fixed salt) — sent to the server for authentication
- Encryption key (PBKDF2 with random salt per item) — encrypts data locally
The server only receives the derived auth key (impossible to reverse to the original password) and already-encrypted blobs. It cannot decrypt anything.
- Registration and login with email + derived password (PBKDF2)
- JWT access token (15 min, memory-only on client)
- JWT refresh token (7 days, HttpOnly cookie with rotation)
- Password change (requires current password)
In a zero-knowledge system, if you lose your password, your encrypted data is unrecoverable. Account reset allows you to regain access by permanently deleting all previous data.
Complete flow:
- User requests reset from the frontend (
POST /auth/forgot-password) - Server generates a cryptographic token (
crypto.randomBytes(32)) and stores its bcrypt hash - An email is sent via SMTP with a link to the frontend containing the token (valid for 1 hour)
- User sets a new password from the link
- Server verifies the token, deletes all VaultItems and DeletionLogs for the user, generates a new
vaultSalt, and saves the new password hash - User can log in with the new password (empty vault)
Reset security measures:
- Token hashed with bcrypt in the database (never stored as plaintext)
- Strict 1-hour expiration
- Single-use: cleared after successful reset
- Aggressive rate limit: 3 requests per 15 minutes
- Generic response that does not reveal whether the email exists
- CRUD for encrypted items with Last-Write-Wins (LWW)
- Batch push/pull (max 50 items per request)
- DeletionLog for propagating deletions across devices
- Incremental sync via timestamps
# Clone
git clone <repo-url>
cd tools-sync-api
# Install dependencies
npm install
# Configure environment variables
cp .env.example .env
# Edit .env with your values
# Development
npm run dev
# Production
npm startThe server starts on
http://localhost:3001by default.
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 3001 |
MONGODB_URI |
MongoDB connection URI | mongodb://127.0.0.1:27017/tools-sync |
NODE_ENV |
Environment (development / production) |
development |
MAX_PAYLOAD_SIZE |
Maximum body size | 50mb |
CORS_ORIGIN |
Allowed origin (frontend URL) | http://localhost:5173 |
FRONTEND_URL |
Frontend URL (for email links) | http://localhost:5173 |
| Variable | Description | Default |
|---|---|---|
JWT_SECRET |
Secret for access tokens | - |
JWT_REFRESH_SECRET |
Secret for refresh tokens | - |
JWT_EXPIRES_IN |
Access token expiration | 15m |
JWT_REFRESH_EXPIRES_IN |
Refresh token expiration | 7d |
COOKIE_DOMAIN |
Domain for cross-subdomain cookies | (empty) |
| Variable | Description | Default |
|---|---|---|
SMTP_HOST |
SMTP server host | - |
SMTP_PORT |
SMTP port | 465 |
SMTP_SECURE |
SSL/TLS (true for 465, false for 587) |
true |
SMTP_USER |
SMTP user (authentication account) | - |
SMTP_PASS |
SMTP password | - |
SMTP_FROM |
Sender email (can be an alias) | - |
| Method | Route | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /auth/register |
No | 10/15min | Register with email + derived authKey |
| POST | /auth/login |
No | 10/15min | Login, returns access token + refresh cookie |
| POST | /auth/refresh |
Cookie | 10/15min | Rotates refresh token, returns new access token |
| POST | /auth/change-password |
Bearer | - | Change password (requires current password) |
| POST | /auth/logout |
Bearer | - | Invalidates refresh token and clears cookie |
| POST | /auth/forgot-password |
No | 3/15min | Sends email with reset token |
| POST | /auth/verify-reset-token |
No | 10/15min | Verifies token without consuming it |
| POST | /auth/reset-account |
No | 10/15min | New password + deletes all vault data |
All endpoints require Authorization: Bearer <token>.
| Method | Route | Description |
|---|---|---|
| GET | /vault/sync-status?since= |
Item timestamps + deletions since timestamp |
| GET | /vault/:storeName |
List items in a store (without payload) |
| GET | /vault/:storeName/:itemId |
Full item with encrypted payload |
| PUT | /vault/:storeName/:itemId |
Upsert (rejects if updatedAt < existing) |
| DELETE | /vault/:storeName/:itemId |
Delete item + create DeletionLog entry |
| POST | /vault/batch-push |
Batch upsert (max 50 items) |
| POST | /vault/batch-pull |
Batch fetch by IDs (max 50) |
| Field | Type | Description |
|---|---|---|
email |
String | Unique, lowercase, validated |
passwordHash |
String | bcrypt(authKey, 12 rounds) |
vaultSalt |
[Number] | Salt for client-side key derivation |
refreshTokenHash |
String | null | Hash of active refresh token |
resetTokenHash |
String | null | bcrypt hash of reset token |
resetTokenExpiry |
Date | null | Reset token expiry (1h from creation) |
createdAt |
Date | Registration date |
| Field | Type | Description |
|---|---|---|
userId |
ObjectId | Reference to User |
storeName |
String | Store name (enum) |
itemId |
String | Unique item ID |
itemName |
String | Display name |
encryptedPayload |
Object | { salt, iv, data } in Base64 |
payloadSize |
Number | Bytes of decoded data field |
updatedAt |
Number | Client timestamp (ms) |
Compound index: { userId, storeName, itemId } unique.
| Field | Type | Description |
|---|---|---|
userId |
ObjectId | Reference to User |
storeName |
String | Store of deleted item |
itemId |
String | Deleted item ID |
deletedAt |
Number | Timestamp (ms) |
| Mechanism | Detail |
|---|---|
| JWT | Access token 15 min (memory-only) + refresh token 7 days (HttpOnly cookie, rotation) |
| HttpOnly cookies | Refresh token in secure cookie (Secure, SameSite=Strict, HttpOnly) |
| bcrypt | 12 rounds for passwordHash, 10 rounds for tokens |
| Reset tokens | crypto.randomBytes(32), hashed, expire in 1h, single-use |
| Rate limiting | Auth: 10/15min, Forgot-password: 3/15min, API: 300/15min, Batch: 10/min |
| Helmet | HTTP security headers |
| CORS | Configured origin only, credentials: true |
| Payload limits | 10 MB per item, 50 MB total body |
| Validation | express-validator on all routes |
| No data leaks | Forgot-password always responds 200 (does not reveal account existence) |
- The
updatedAtfield (client timestamp in ms) determines which version wins - Server rejects PUT if
incoming.updatedAt < existing.updatedAt - DeletionLog enables propagating deletions across devices
1. GET /vault/sync-status?since=lastSync
2. Compare local vs remote timestamps
3. Pull newer remote items
4. Push newer local items
5. Process deletions
tools-sync-api/
├── src/
│ ├── server.js # Entry point + middleware setup
│ ├── config/
│ │ └── db.js # MongoDB connection
│ ├── middleware/
│ │ ├── auth.js # JWT verify middleware
│ │ ├── rateLimiter.js # Rate limiters (auth, api, batch, forgot)
│ │ └── errorHandler.js # Global error handler
│ ├── models/
│ │ ├── User.js # User schema + reset tokens
│ │ ├── VaultItem.js # Encrypted vault items
│ │ └── DeletionLog.js # Deletion records
│ ├── routes/
│ │ ├── auth.js # Authentication + reset routes
│ │ └── vault.js # Vault CRUD routes
│ ├── controllers/
│ │ ├── authController.js # Auth + forgot/reset logic
│ │ └── vaultController.js # Vault CRUD + sync logic
│ └── utils/
│ └── email.js # Nodemailer transporter + templates
├── .env
├── .env.example
└── package.json
| Package | Version | Description |
|---|---|---|
| Express | 4.21 | Minimalist web framework |
| Mongoose | 8.9 | MongoDB ODM |
| bcrypt | 5.1 | Password and token hashing |
| jsonwebtoken | 9.0 | JWT tokens (access + refresh) |
| nodemailer | 7.0 | Email sending via SMTP |
| helmet | 8.0 | HTTP security headers |
| cors | 2.8 | Cross-Origin Resource Sharing |
| cookie-parser | 1.4 | Parse cookies in requests |
| express-rate-limit | 7.5 | IP-based rate limiting |
| express-validator | 7.2 | Input validation and sanitization |
| dotenv | 16.4 | Environment variables from .env |
- Node.js 22+
- MongoDB 6+ (local or Atlas)
- SMTP server for reset emails
- SSL certificate (HTTPS required for Secure cookies)
NODE_ENV=production
JWT_SECRET=<random-64-chars>
JWT_REFRESH_SECRET=<random-64-chars>
COOKIE_DOMAIN=.your-domain.com
CORS_ORIGIN=https://your-frontend.com
FRONTEND_URL=https://your-frontend.com
SMTP_HOST=mail.your-domain.com
[email protected]
SMTP_PASS=<password>
[email protected]HttpOnly cookies with
SameSite=StrictandSecure=truerequire frontend and backend to share the same registrable domain (e.g.,tools.example.comandsync.example.com).
- Fork the repository
- Create a branch (
git checkout -b feature/improvement) - Commit your changes (
git commit -m 'feat: description') - Push to the branch (
git push origin feature/improvement) - Open a Pull Request
This project is licensed under the MIT License. See the LICENSE file for details.
Part of the Web Tools project — Zero-knowledge encrypted tools suite