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;
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"} /> } // ... }
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;
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)