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
CryptoKeyis 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
CryptoKeyusing 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)