DEV Community

Sergey Chin
Sergey Chin

Posted on • Edited on

Secure Note Manager in React – Part 1: Cryptography with Web Crypto API

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: []
  }
]);
Enter fullscreen mode Exit fullscreen mode

RootApp is our main layout component:

import { Outlet } from "react-router-dom";

const RootApp: React.FC = () => {
  return <Outlet />;
};

export default RootApp;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Let's break down the code step by step.

async deriveSecretKey(masterPassword: string, passwordSalt: ArrayBuffer): Promise<CryptoKey>
Enter fullscreen mode Exit fullscreen mode

This function takes two inputs:

  • masterPassword: a string entered by the user.
  • passwordSalt: a random value (as ArrayBuffer) 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"]
);
Enter fullscreen mode Exit fullscreen mode
  • 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"]
);
Enter fullscreen mode Exit fullscreen mode

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)
  };
}
Enter fullscreen mode Exit fullscreen mode

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 the CryptoKey we previously derived from the password.
  • textEncoder.encode(text) turns the text string into bytes before encryption.
  • The result (encrypted) is an ArrayBuffer 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 ArrayBuffers 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • This function exports a CryptoKey back into its raw (byte) format as an ArrayBuffer.
  • 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: The CryptoKey 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));
}
Enter fullscreen mode Exit fullscreen mode
  • 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 like generateSalt(), 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));
}
Enter fullscreen mode Exit fullscreen mode

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)