Introduction
There are plenty of applications for securely managing notes and passwords. I thought it would be a great learning experience to try building one myself.
In this series, I’ll walk you through building a secure note manager that:
- Runs entirely in the browser
- Encrypts all data on the client side
- Requires no backend
- Never sends any sensitive data over the network
We’ll use the following tech stack:
- React – for the UI and app logic
- Web Crypto API – for secure, browser-native cryptographic operations
- IndexedDB – for persistent storage in the browser
In this first part, we’ll focus on building the cryptographic foundation: deriving keys from passwords, encrypting and decrypting text, and handling binary data.
The full project is available on GitHub.
You can play with the completed app here.
Project Setup
The application scaffold was created using the Vite build tool (https://vite.dev/).
Routing with React Router
Routing is configured in /src/pages/routes.tsx
:
import { createBrowserRouter } from "react-router-dom";
import RootApp from "./app/RootApp";
export const router = createBrowserRouter([
{
path: "/",
element: <RootApp />,
children: []
}
]);
RootApp
is our main layout component:
import { Outlet } from "react-router-dom";
const RootApp: React.FC = () => {
return <Outlet />;
};
export default RootApp;
Right now it doesn't do anything, but we’ll add more functionality later.
Redux Store Setup
Redux is configured in /src/store/index.ts
:
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
In main.tsx
, we wrap the app with the store and router providers:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { RouterProvider } from 'react-router-dom';
import { router } from './pages/routes.tsx';
import { Provider } from 'react-redux';
import { store } from './store/index.ts';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
</StrictMode>
);
Implementing Cryptography
All cryptography utilities are implemented in /src/utils/cryptoUtils.ts
file.
Key Derivation with PBKDF2
To safely encrypt data, we first need to turn the user’s password into a strong cryptographic key. We use PBKDF2 for this:
async deriveSecretKey(masterPassword: string, passwordSalt: ArrayBuffer): Promise<CryptoKey> {
const masterPasswordRawKey = await crypto.subtle.importKey(
"raw",
textEncoder.encode(masterPassword),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
const derivedKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: passwordSalt,
iterations: 100_000,
hash: "SHA-256"
},
masterPasswordRawKey,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
return derivedKey;
}
Let's break down the code step by step.
async deriveSecretKey(masterPassword: string, passwordSalt: ArrayBuffer): Promise<CryptoKey>
This function takes two inputs:
-
masterPassword
: a string entered by the user. -
passwordSalt
: a random value (asArrayBuffer
) that makes the key derivation more secure.
It returns a CryptoKey
, which is a special object used by the browser's WebCrypto API to perform cryptographic operations like encryption and decryption.
const masterPasswordRawKey = await crypto.subtle.importKey(
"raw",
textEncoder.encode(masterPassword),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
- We import the plain password into the WebCrypto API.
-
textEncoder.encode(masterPassword)
turns the string into bytes (needed for cryptography). -
"raw"
tells WebCrypto that we're importing raw binary data (not an already-formatted key). -
{ name: "PBKDF2" }
means we’ll use this key with the PBKDF2 algorithm (Password-Based Key Derivation Function 2). - The last two values:
-
false
= this key is not extractable (we can’t convert it back to bytes later). -
["deriveKey"]
= this key can only be used to derive another key.
-
So now, we’ve turned the plain-text password into a usable format for key derivation.
const derivedKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: passwordSalt,
iterations: 100000,
hash: "SHA-256"
},
masterPasswordRawKey,
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
Now we use PBKDF2 to transform the raw key (password) into a strong cryptographic key.
-
salt
: A random value that prevents attackers from using precomputed hash tables (a.k.a. rainbow tables). Each user or session should have a different salt. -
iterations: 100000
: This makes the function slower on purpose, which increases security by making brute-force attacks expensive. -
hash: "SHA-256"
: The hashing algorithm used within PBKDF2. A modern and secure choice.
The rest of the parameters:
- The
masterPasswordRawKey
we created earlier is the input. - The third argument
{ name: "AES-GCM", length: 256 }
specifies the output key:- We'll use the key with AES-GCM (a secure encryption algorithm).
- The key length is 256 bits, which is considered strong.
-
true
: This key is extractable (you can export it if needed). -
["encrypt", "decrypt"]
: This key can be used for both encrypting and decrypting data.
So this step outputs a usable AES key derived from the user's password.
Encryption & Decryption with AES-GCM
async encryptText(key: CryptoKey, text: string): Promise<EncryptionResult> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // Random IV
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
textEncoder.encode(text)
);
return {
ciphertext: this.arrayBufferToBase64(encrypted),
iv: this.arrayBufferToBase64(iv)
};
}
This function takes in:
-
key
: A cryptographic key (derived from the user's password) -
text
: The plain-text string we want to encrypt
It returns a Promise
that resolves to an EncryptionResult
, which includes the encrypted text and the IV (initialization vector). The IV (Initialization Vector) is like a random "salt" used just once per encryption operation. It's required for AES-GCM to ensure that even if you encrypt the same text twice, the result looks different. 12 bytes (96 bits) is the recommended size for AES-GCM.
Next we're calling the browser’s built-in encrypt
function.
- The algorithm is AES-GCM, using the IV we just generated.
-
key
is theCryptoKey
we previously derived from the password. -
textEncoder.encode(text)
turns the text string into bytes before encryption. - The result (
encrypted
) is anArrayBuffer
containing the ciphertext (the encrypted data).
In the end we return both the encrypted data and the IV, but they are encoded as Base64 strings. This makes it easier to save or transmit the result later, because ArrayBuffer
s can’t be stored directly in most formats (like JSON or local storage).
Decryption looks like this:
async decryptText(key: CryptoKey, ciphertext: string, iv: string): Promise<string> {
const ciphertextBytes = this.base64ToArrayBuffer(ciphertext);
const ivBytes = this.base64ToArrayBuffer(iv);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivBytes },
key,
ciphertextBytes
);
return textDecoder.decode(decrypted);
}
- Decodes Base64 to binary.
- Decrypts the data using AES-GCM.
- Returns the original plain text.
Other Cryptographic Helpers
SHA-512 Hashing
Hashing is used for comparing passwords or verifying data integrity. The next code hashes the input message using SHA-512, then encodes the result in Base64
async digestAsBase64(message: ArrayBuffer | DataView): Promise<string> {
const digest = await crypto.subtle.digest("SHA-512", message);
return this.arrayBufferToBase64(digest);
}
-
crypto.subtle.digest(...)
: Generates a cryptographic hash of the input. -
"SHA-512"
: A very strong hashing algorithm (512 bits). - The result is a hash digest, which we convert to a readable format using
arrayBufferToBase64
.
Export Keys
async exportKey(key: CryptoKey): Promise<ArrayBuffer> {
return await crypto.subtle.exportKey("raw", key);
}
- This function exports a
CryptoKey
back into its raw (byte) format as anArrayBuffer
. - Useful if you want to store or transfer the key (e.g. in IndexedDB or across sessions).
-
'raw'
: We want to extract the raw binary version of the key. -
key
: TheCryptoKey
you want to export.
Salt Generation
generateSalt(): ArrayBuffer {
return crypto.getRandomValues(new Uint8Array(16));
}
generateSaltAsBase64(): string {
const salt = crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...salt));
}
- Generates a random salt (a 16-byte
ArrayBuffer
). - Salts make password-based key derivation stronger by making each password unique.
- 128 bits is a common length for salts and is generally sufficient for security.
-
generateSaltAsBase64()
is just likegenerateSalt()
, but it returns the result as a Base64 string. This makes it easier to store in formats like JSON or HTML attributes.
Encoding Utilities
arrayBufferToBase64(arr: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(arr)));
}
base64ToArrayBuffer(base64: string): ArrayBuffer {
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}
These two utility functions help us store and transmit binary data (like ciphertext and IVs) by converting it to/from a Base64-encoded string.
Summary
We’ve created the core of a secure browser-based note manager:
- Derived AES encryption keys from user passwords
- Encrypted and decrypted notes using AES-GCM
- Used random salts and IVs to enhance security
- Encoded binary data safely for storage
These low-level functions form the cryptographic backbone of our app.
In Part 2, we’ll put this crypto toolkit into action.
Top comments (0)