Secure Password Manager
A full-stack password manager where the backend never sees plaintext vault data — the React frontend derives an encryption key from the master password via the Web Crypto API and encrypts everything client-side before it ever reaches the Django REST API. Backed by PostgreSQL, with email and TOTP multi-factor authentication.
Highlights
- $ Client-side encryption via the Web Crypto API: a key is derived from the master password with and a random salt, then used for encryption — the backend only ever stores a ciphertext, never the plaintext or the master password.
- $ Two active MFA methods: email (time-limited verification code) and TOTP (QR-code enrollment via pyotp/qrcode, compatible with any authenticator app). SMS MFA via Twilio is scaffolded in the codebase but not yet wired into the active routes.
- $ MFA enforcement via a dedicated MFARequiredIfOptedIn DRF permission class that checks session-level verification state for any user who has MFA enabled.
- $ VaultItem records support soft-delete and optional expiry, with a separate VaultItemHistory model logging actions taken on each item.
- $ React 19 frontend built with Vite and integrated into Django via django-vite, with 480+ lines of DRF test coverage (APIClient-based) around login and MFA flows.
Architecture
Trade-offs & decisions
Client-side encryption vs. server-side
The master password and derived key never leave the browser — the server stores only ciphertext plus a salt/IV bundle, so it can't read vault contents even if the database were compromised. The cost: there's no way to recover a vault if a user forgets their master password, since the server has no way to decrypt it either.
Django + React via django-vite vs. a fully decoupled SPA
Vite builds the React frontend into static assets that Django serves directly (django-vite wires up the manifest), keeping frontend and backend on one origin and avoiding cross-origin cookie/CORS complexity — at the cost of a slightly less independent frontend deploy than a fully separate SPA would have.
Multiple MFA methods vs. one
Supporting both email and TOTP (with SMS scaffolded) lets users pick whichever they have available, at the cost of more enrollment and verification code paths to build, test, and keep secure.
Dev-only login endpoint, disabled in production
A credentials-login endpoint used by the Postman test collection to simulate an authenticated session is explicitly checked and rejected (403) whenever DEBUG=False, so a convenience path built for local testing can't be reached in production.
Code excerpt
// Derive an AES-GCM key from the master password via PBKDF2
async function deriveKey(password, salt) {
const keyMaterial = await crypto.subtle.importKey(
'raw', strToBuf(password), { name: 'PBKDF2' }, false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
export default async function encryptJson(jsonObj, password) {
const salt = getRandomBytes(16);
const iv = getRandomBytes(12);
const key = await deriveKey(password, salt);
const plaintext = strToBuf(JSON.stringify(jsonObj));
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, plaintext
));
// Only this bundle is ever sent to the server
return [bufToB64(salt), bufToB64(iv), bufToB64(ciphertext)].join(':');
}