DEV Community

Sergey Chin
Sergey Chin

Posted on • Edited on

Secure Note Manager in React - Part 2. Client-Side Login with Web Crypto and Redux

Introduction

In this second part of the series, we’ll build a simple yet secure login page — entirely without a backend. Using only browser-based cryptography, local storage, and Redux, we’ll create a seamless login experience.

Here’s what we’ll implement:

  • An in-memory secret key store using Redux.
  • Protection for the main vault page based on key presence.
  • A login form that authorizes the user via a master password.

The full project is available on GitHub.

You can play with the completed app here.

In-Memory Secret Key Store

To securely hold the cryptographic key during a session, we use Redux to store it in memory only. This means the key disappears when the app reloads — which is exactly what we want for handling sensitive data.

Here’s the vault slice:

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface VaultSliceType {
    // Stores a CryptoKey used for encryption/decryption in memory
  secretKey: CryptoKey | null;
  // Numeric toggle used to manually trigger reactivity in components
  updateTrigger: number;
}

// Function to return the initial state for the vault slice
const getInitialState = (): VaultSliceType => {
  return { 
      secretKey: null, // No key initially
      updateTrigger: 0 // Default trigger value
  };
};

// Create the Redux slice for vault state management
const vaultSlice = createSlice({
  name: "vault",
  initialState: getInitialState(),
  reducers: {
      // Action to store a new CryptoKey in state
    updateSecretKey: (state, action: PayloadAction<CryptoKey>) => {
      if (action.payload) {
        state.secretKey = action.payload;
      }
    },

    // Action to toggle the updateTrigger value (0 <-> 1)
    // Useful for manually triggering updates in subscribers (e.g., React components)
    triggerUpdate: (state) => {
      state.updateTrigger = state.updateTrigger === 0 ? 1 : 0;
    },
  },
  selectors: {
      // Selector to retrieve the trigger updates
    getUpdateTrigger: (state) => {
      return state.updateTrigger;
    },
    // Selector to retrieve the secret key
    getSecretKey: (state) => {
      return state.secretKey;
    },
  },
});

export const { updateSecretKey, triggerUpdate } = vaultSlice.actions;
export const { getSecretKey, getUpdateTrigger } = vaultSlice.selectors;
export default vaultSlice;
Enter fullscreen mode Exit fullscreen mode

Why triggerUpdate is Needed

This is a technique to trigger reactivity in the UI when the key changes or vault data is updated. By toggling updateTrigger, we signal to React components that the vault state has changed.

Security Considerations

  • The CryptoKey is stored only in memory and never persisted.
  • Once the app reloads or closes, the key is lost by design.
  • This is ideal for secure, ephemeral sessions tied to a master password.

Securing Access to Vault Page

To ensure that only authenticated users can access the vault, we check whether a CryptoKey is present in Redux. If it's missing, the user is redirected to the login page.

import { useSelector } from "react-redux";
import { getSecretKey } from "@/store/slices/vaultSlice";

const VaultPage = () => {
    const secretKey = useSelector(getSecretKey);

    if (!secretKey) {
        return <Navigate to={"/auth/local-login"} />
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

We'll complete the rest of the VaultPage component in an upcoming part of this series.

Login Page

Now let’s build the login interface. This component accepts a master password, derives a CryptoKey from it using a salt, and checks whether the derived key matches the stored digest. If the match is successful, the key is stored in Redux and the user is allowed to proceed.

import { localStorageService } from "@/storage/local-storage/localStorageService";
import { triggerUpdate, updateSecretKey } from "@/store/slices/vaultSlice";
import { cryptoUtils } from "@/utils/cryptoUtils";
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";

// This component implements the local login flow using a master password
const LocalLoginPage = () => {
  // React state to hold the user's input
  const [masterPassword, setMasterPassword] = useState<string>("");

  // These values are read once from localStorage at render time.
  // 1. The digest (hash) of the previously derived key
  // 2. The salt that was originally used to derive the key
  const storedSecretKeyDigest = useSelector(() => localStorageService.getSecretKeyDigestBase64());
  const storedSalt = useSelector(() => localStorageService.getSecretKeySalt());

  // Redux dispatch and router navigation functions
  const dispatch = useDispatch();
  const navigate = useNavigate();

  // Handles form submission (when user clicks "Submit")
  const handleSubmit = async (e: any) => {
    e.preventDefault();

    // If a salt was previously stored, reuse it; otherwise generate a new one
    const salt = storedSalt !== null ? storedSalt : cryptoUtils.generateSalt();

    // Derive a CryptoKey from the password and salt using PBKDF2
    const secretKey = await cryptoUtils.deriveSecretKey(masterPassword, salt);

    // Export the CryptoKey to ArrayBuffer for hashing
    const secretKeyExported = await cryptoUtils.exportKey(secretKey);

    // Create a base64-encoded digest of the exported key (used for verification)
    const secretKeyHash = await cryptoUtils.digestAsBase64(secretKeyExported);

    // If a stored digest exists and doesn't match the current one, password is wrong
    if (storedSecretKeyDigest && storedSecretKeyDigest !== secretKeyHash) {
      alert("Incorrect master password");
      return;
    }

    // Save the salt and digest for future validation (not the actual key!)
    localStorageService.setSecretKeySalt(cryptoUtils.arrayBufferToBase64(salt));
    localStorageService.setSecretKeyDigest(secretKeyHash);

    // Store the derived CryptoKey in Redux state (in-memory only)
    dispatch(updateSecretKey(secretKey));

    // Trigger a Redux update so any dependent components can react
    dispatch(triggerUpdate());

    // Navigate to the vault after successful login
    navigate("/vault", { flushSync: true });
  };

  // Render the form UI
  return <>
    {/* Top navigation bar */}
    <div className="navbar bg-base-100">
      <div className="navbar-start" />
      <div className="navbar-center">
        <a className="btn btn-ghost text-xl">Local Auth</a>
      </div>
      <div className="navbar-end" />
    </div>

    {/* Main login form */}
    <div className="m-4">
      <form onSubmit={handleSubmit}>
        <fieldset className="fieldset w-full">
          <legend className="fieldset-legend text-sm">Master Password</legend>

          {/* Input for the master password */}
          <input 
            value={masterPassword}
            onChange={e => setMasterPassword(e.target.value)}
            type="password"
            className="input w-full"
            placeholder="Input password..."
            required
          />
        </fieldset>

        {/* Submit button */}
        <div className="flex">
          <div className="flex-1 px-1">
            <button className="btn btn-primary mt-5 w-full" type="submit">
              Submit
            </button>
          </div>
        </div>
      </form>
    </div>
  </>;
};

export default LocalLoginPage;

Enter fullscreen mode Exit fullscreen mode

Summary of The Code Above

User Enters Password

The master password is captured via a controlled input.

Key Derivation with Salt

Using PBKDF2, the app derives a CryptoKey from the password and salt. This is standard practice to make password-based cryptography secure and resistant to brute-force attacks.

Verification via Digest

If a key was already registered (a digest exists), the newly derived key is hashed and compared with the stored digest. If it doesn't match — the password is wrong.

Secure In-Memory Key Storage

If verification passes (or this is first-time login), the app saves:

  • The salt and digest to localStorage
  • The actual key to Redux state only, never persisted

Vault Access Granted

On success, the user is navigated to /vault, and any components depending on the vault state will react via Redux.

Summary

In this part, we:

  • Implemented an in-memory store for a CryptoKey using Redux.
  • Protected the vault route by checking for key presence.
  • Built a secure, backend-free login page using PBKDF2 and local storage.

This approach keeps the user's encryption key off disk, supports secure session-based access, and allows for a fully offline login experience.

In the next part, we’ll continue building the actual vault UI.

Top comments (0)