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)