zustand-crypto-persist
v0.3.0
Published
Secure, encrypted persistence for Zustand stores with Web Crypto API
Downloads
2
Maintainers
Readme
zustand-crypto-persist
Secure, encrypted persistence for Zustand stores using Web Crypto API. Features include automatic salt generation, IndexedDB support, migration utilities, and React Suspense integration.
Features
- 🔐 Secure Encryption: Uses Web Crypto API for browser-native encryption
- 🧂 Automatic Salt Generation: Unique salts per browser/session for enhanced security
- 💾 Multiple Storage Options: localStorage, sessionStorage, and IndexedDB
- 🔄 Migration Support: Built-in versioning and migration system
- ⚛️ React Suspense Ready: Hooks for seamless async storage integration
- 📦 Zero Dependencies: Only requires Zustand as a peer dependency
- 🌐 TypeScript First: Full type safety and IntelliSense support
- 🚀 Performance Optimized: Minimal overhead with caching strategies
- 🏗️ SSR Compatible: Built-in support for Next.js and other SSR frameworks (v0.3.0+)
Installation
npm install zustand-crypto-persist zustand
# or
yarn add zustand-crypto-persist zustand
# or
pnpm add zustand-crypto-persist zustandQuick Start
Basic Usage
import { createSimpleCryptoStore } from 'zustand-crypto-persist';
// Create an encrypted store with minimal configuration
const useUserStore = createSimpleCryptoStore(
'user-store', // Store name
process.env.CRYPTO_SECRET_KEY!, // Your secret key
(set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
})
);
// Use in your components
function Profile() {
const user = useUserStore((state) => state.user);
return <div>Welcome, {user?.name}!</div>;
}Advanced Configuration
import { createCryptoStore } from 'zustand-crypto-persist';
interface StoreState {
todos: Todo[];
addTodo: (todo: Todo) => void;
removeTodo: (id: string) => void;
}
const useTodoStore = createCryptoStore<StoreState>(
{
name: 'todo-store',
storage: {
secretKey: process.env.CRYPTO_SECRET_KEY!,
storageType: 'indexedDB', // Use IndexedDB for larger data
encrypt: true,
},
version: 1,
migrations: [
{
fromVersion: 0,
toVersion: 1,
migrate: (oldData) => ({
...oldData,
todos: oldData.items || [], // Rename 'items' to 'todos'
}),
},
],
},
(set) => ({
todos: [],
addTodo: (todo) => set((state) => ({
todos: [...state.todos, todo]
})),
removeTodo: (id) => set((state) => ({
todos: state.todos.filter(t => t.id !== id)
})),
})
);Custom Persist Options (v0.2.0+)
You can now pass custom persist options to control hydration, merging, and other behaviors:
const useAuthStore = createCryptoStore<AuthState>(
{
name: 'auth-store',
storage: {
secretKey: process.env.CRYPTO_SECRET_KEY!,
storageType: 'localStorage',
},
persistOptions: {
// Skip automatic hydration (useful for SSR)
skipHydration: true,
// Custom merge strategy
merge: (persistedState, currentState) => {
// Only use persisted state if token is still valid
if (persistedState?.tokenExpiry > Date.now()) {
return { ...currentState, ...persistedState };
}
return currentState;
},
// Handle rehydration events
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('Failed to rehydrate:', error);
} else {
console.log('Store rehydrated successfully');
// Clean up expired data
if (state && !state.isTokenValid()) {
state.clearUserData();
}
}
},
},
},
(set, get) => ({
user: null,
tokenExpiry: 0,
setUser: (user, tokenExpiry) => set({ user, tokenExpiry }),
clearUserData: () => set({ user: null, tokenExpiry: 0 }),
isTokenValid: () => get().tokenExpiry > Date.now(),
})
);React Suspense Integration
Using Suspense with Async Storage
import { createAsyncStore, useSuspenseStore } from 'zustand-crypto-persist/react';
import { createIndexedDBCryptoStore } from 'zustand-crypto-persist';
// Create a store with async support
const useAsyncStore = createIndexedDBCryptoStore(
'async-store',
process.env.CRYPTO_SECRET_KEY!,
createAsyncStore((set) => ({
data: [],
isLoading: false,
fetchData: async () => {
set({ isLoading: true });
const data = await api.fetchData();
set({ data, isLoading: false });
},
}))
);
// Component using Suspense
function DataList() {
// This will trigger Suspense during store initialization
const data = useSuspenseStore(useAsyncStore, (state) => state.data);
return (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// Wrap with Suspense
function App() {
return (
<Suspense fallback={<div>Loading encrypted data...</div>}>
<DataList />
</Suspense>
);
}Crypto Initialization Hook
import { useCryptoInit, withCryptoInit } from 'zustand-crypto-persist/react';
// Hook approach
function MyComponent() {
const { isInitialized, isInitializing, error } = useCryptoInit();
if (isInitializing) return <div>Initializing encryption...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!isInitialized) return <div>Waiting for crypto system...</div>;
return <YourApp />;
}
// HOC approach
const SecureApp = withCryptoInit(
YourApp,
() => <div>Loading...</div>,
({ error }) => <div>Error: {error.message}</div>
);Storage Options
localStorage (Default)
const useStore = createCryptoStore({
name: 'my-store',
storage: {
secretKey: 'your-secret-key',
storageType: 'localStorage',
},
}, initialState);sessionStorage
const useStore = createCryptoStore({
name: 'session-store',
storage: {
secretKey: 'your-secret-key',
storageType: 'sessionStorage',
},
}, initialState);IndexedDB (Recommended for large data)
const useStore = createCryptoStore({
name: 'indexed-store',
storage: {
secretKey: 'your-secret-key',
storageType: 'indexedDB',
dbName: 'MyAppDB', // Optional custom DB name
storeName: 'MyStore', // Optional custom store name
},
}, initialState);Migration System
Handle data structure changes between versions:
import { createMigration, composeMigrations } from 'zustand-crypto-persist/migration';
const migrations = composeMigrations(
createMigration(0, 1, (data) => ({
...data,
// Add new field with default value
settings: data.settings || { theme: 'light' },
})),
createMigration(1, 2, (data) => ({
...data,
// Restructure data
user: {
profile: data.userProfile,
preferences: data.settings,
},
})),
);
const useStore = createCryptoStore({
name: 'app-store',
storage: { secretKey: 'key' },
version: 2,
migrations,
}, initialState);Manual Crypto Operations
For advanced use cases, you can use the crypto utilities directly:
import { encryptData, decryptData, SaltManager } from 'zustand-crypto-persist';
// Encrypt data
const encrypted = await encryptData('sensitive data', 'secret-key');
// Decrypt data
const decrypted = await decryptData(encrypted, 'secret-key');
// Manage salt
const salt = await SaltManager.getSalt();
await SaltManager.clearSalt(); // Clear on logoutAPI Reference
Core Functions
createCryptoStore(config, initialState)
Creates a Zustand store with encrypted persistence.
Parameters:
config: Configuration objectname: Store name (string)storage: Storage optionssecretKey: Encryption key (string)storageType: 'localStorage' | 'sessionStorage' | 'indexedDB'encrypt: Enable encryption (boolean, default: true)
version: Store version (number, default: 1)migrations: Array of migration functionspersistOptions: Custom persist options (v0.2.0+)skipHydration: Skip automatic hydration (boolean)merge: Custom merge functiononRehydrateStorage: Rehydration callback- All other Zustand persist options
initialState: State creator function
createSimpleCryptoStore(name, secretKey, initialState, persistOptions?)
Creates a basic encrypted store with minimal configuration.
Parameters:
name: Store name (string)secretKey: Encryption key (string)initialState: State creator functionpersistOptions: Optional persist options (v0.2.0+)
createIndexedDBCryptoStore(name, secretKey, initialState, persistOptions?)
Creates an encrypted store using IndexedDB.
Parameters:
name: Store name (string)secretKey: Encryption key (string)initialState: State creator functionpersistOptions: Optional persist options (v0.2.0+)
Storage Functions
initializeCrypto()
Initializes the crypto system. Called automatically but can be called manually for eager initialization.
waitForCryptoInit()
Returns a promise that resolves when crypto is initialized.
isCryptoInitialized()
Check if the crypto system is ready.
React Hooks
useSuspenseStore(store, selector?)
Use a store with React Suspense support.
useCryptoInit()
Hook to check crypto initialization status.
withCryptoInit(Component, Loading?, Error?)
HOC that ensures crypto is initialized before rendering.
Migration Utilities
createMigration(fromVersion, toVersion, migrateFn)
Create a migration function.
composeMigrations(...migrations)
Combine multiple migrations in order.
Security Considerations
⚠️ IMPORTANT SECURITY WARNING ⚠️
The default implementation provides obfuscation, NOT true security:
- The salt is stored in IndexedDB (accessible via browser DevTools)
- The secret key is embedded in your JavaScript bundle (visible to anyone)
- This is suitable ONLY for non-sensitive data that needs basic obfuscation
- For true security, use password-based encryption (see below)
Default Mode Limitations
Secret Key Exposure:
- Your secret key is visible in the JavaScript bundle
- Anyone can extract it using browser DevTools
- Environment variables don't hide keys from the client
Salt Accessibility:
- Salt is stored in plain text in IndexedDB
- Accessible via browser DevTools → Application → IndexedDB
- Provides uniqueness, not security
Suitable Use Cases:
- User preferences that aren't sensitive
- UI state that needs persistence
- Data you want to obfuscate from casual inspection
- NOT suitable for: passwords, API keys, personal data, financial info
Browser Storage Limitations
- Data is only as secure as the browser's storage
- XSS attacks can still access encrypted data if they have the key
- Browser extensions can potentially access storage
- Always use HTTPS to prevent man-in-the-middle attacks
True Security with Password-Based Encryption (WIP)
For sensitive data that requires actual security, use password-based encryption where the user provides the password at runtime.
Why Password-Based Encryption is Secure
- No Key in Code: The encryption key is derived from the user's password
- PBKDF2 Key Derivation: Makes brute-force attacks computationally expensive
- User-Controlled: Only the user knows the password
- Memory-Only: Password never needs to be stored
Implementation Example
import { createCryptoStore } from 'zustand-crypto-persist';
import { deriveKeyFromPassword } from 'zustand-crypto-persist/crypto';
interface SecureState {
sensitiveData: string | null;
isUnlocked: boolean;
unlock: (password: string) => Promise<boolean>;
lock: () => void;
setSensitiveData: (data: string) => void;
}
// Store for managing the encrypted store instance
let secureStoreInstance: ReturnType<typeof createCryptoStore> | null = null;
export const useSecureStore = create<SecureState>((set, get) => ({
sensitiveData: null,
isUnlocked: false,
unlock: async (password: string) => {
try {
// Derive key from password
const { key, salt } = await deriveKeyFromPassword(password);
// Create/recreate the encrypted store with the derived key
secureStoreInstance = createCryptoStore(
{
name: 'secure-user-data',
storage: {
secretKey: key,
salt: salt, // Use the same salt for consistent key derivation
storageType: 'indexedDB',
},
},
(innerSet) => ({
data: get().sensitiveData || '',
setData: (data: string) => {
innerSet({ data });
set({ sensitiveData: data });
},
})
);
// Load existing data
const state = secureStoreInstance.getState();
set({ sensitiveData: state.data, isUnlocked: true });
return true;
} catch (error) {
console.error('Failed to unlock:', error);
return false;
}
},
lock: () => {
secureStoreInstance = null;
set({ sensitiveData: null, isUnlocked: false });
},
setSensitiveData: (data: string) => {
if (secureStoreInstance) {
secureStoreInstance.getState().setData(data);
set({ sensitiveData: data });
}
},
}));
// Usage in component
function SecureDataManager() {
const [password, setPassword] = useState('');
const { isUnlocked, unlock, lock, sensitiveData } = useSecureStore();
const handleUnlock = async () => {
const success = await unlock(password);
if (success) {
setPassword(''); // Clear password from memory
} else {
alert('Invalid password');
}
};
if (!isUnlocked) {
return (
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
/>
<button onClick={handleUnlock}>Unlock</button>
</div>
);
}
return (
<div>
<h3>Secure Data: {sensitiveData}</h3>
<button onClick={lock}>Lock</button>
</div>
);
}Password-Based Encryption Utilities
// Example usage of password crypto utilities
import {
deriveKeyFromPassword,
encryptWithPassword,
decryptWithPassword
} from 'zustand-crypto-persist/crypto';
// Derive a key from password (happens once per session)
const { key, salt } = await deriveKeyFromPassword('user-password');
// Or encrypt/decrypt directly with password
const encrypted = await encryptWithPassword('sensitive data', 'user-password');
const decrypted = await decryptWithPassword(encrypted, 'user-password');Security Comparison
| Feature | Default Mode | Password-Based Mode | |---------|--------------|-------------------| | Key Storage | In JavaScript bundle | Derived from user password | | Salt Storage | IndexedDB (visible) | Generated per password | | Security Level | Obfuscation only | Actual encryption | | Use Cases | UI preferences, non-sensitive data | Personal data, credentials, sensitive info | | User Experience | Automatic | Requires password entry | | Key Accessibility | Anyone can extract | Only with correct password |
Best Practices for Password-Based Encryption
Password Requirements:
- Enforce minimum length (12+ characters recommended)
- Encourage passphrases over complex passwords
- Never store the password, only use it to derive keys
User Experience:
- Provide "Remember for this session" option (store derived key in memory)
- Clear sensitive data on browser close or inactivity
- Implement proper password reset flows
Additional Security:
- Use 2FA for password reset flows
- Implement rate limiting for unlock attempts
- Consider biometric authentication where available
Implementation Tips:
- Derive the key once per session and reuse
- Clear derived keys from memory when locking
- Use high iteration counts for PBKDF2 (100,000+)
Performance Tips
- Use IndexedDB for large datasets - It handles async operations better
- Initialize crypto early - Call
initializeCrypto()in your app root - Batch updates - Minimize the number of state updates
- Use selectors - Only subscribe to the data you need
SSR and Next.js Integration
Overview
Server-Side Rendering (SSR) presents unique challenges for encrypted storage:
- Crypto APIs are browser-only (no Node.js support)
- Hydration mismatches when server and client states differ
- Async initialization of encrypted stores
zustand-crypto-persist provides several strategies to handle these challenges. As of v0.3.0, the library includes built-in SSR compatibility with automatic browser detection.
Basic Next.js Setup
1. Skip Hydration (Simplest Approach)
// app/stores/user-store.ts
import { createSimpleCryptoStore } from 'zustand-crypto-persist';
// Use skipHydration to prevent SSR issues
export const useUserStore = createSimpleCryptoStore(
'user-store',
process.env.NEXT_PUBLIC_CRYPTO_KEY!,
(set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
}),
{
skipHydration: true, // Prevents hydration mismatches
}
);2. Manual Rehydration in useEffect
// app/components/user-profile.tsx
'use client';
import { useEffect, useState } from 'react';
import { useUserStore } from '@/stores/user-store';
export function UserProfile() {
const [isHydrated, setIsHydrated] = useState(false);
const user = useUserStore((state) => state.user);
useEffect(() => {
// Manually trigger rehydration after mount
useUserStore.persist.rehydrate();
setIsHydrated(true);
}, []);
if (!isHydrated) {
return <div>Loading...</div>;
}
return <div>Welcome, {user?.name || 'Guest'}!</div>;
}Advanced Next.js Patterns
1. Layout Component with Crypto Initialization
// app/components/crypto-provider.tsx
'use client';
import { useEffect, useState } from 'react';
import { initializeCrypto, waitForCryptoInit } from 'zustand-crypto-persist';
export function CryptoProvider({ children }: { children: React.ReactNode }) {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// Initialize crypto system on mount
initializeCrypto()
.then(() => waitForCryptoInit())
.then(() => setIsReady(true))
.catch((err) => setError(err as Error));
}, []);
if (error) {
return (
<div className="error-boundary">
<h2>Encryption Error</h2>
<p>{error.message}</p>
</div>
);
}
if (!isReady) {
return (
<div className="loading-spinner">
Initializing secure storage...
</div>
);
}
return <>{children}</>;
}
// app/layout.tsx
import { CryptoProvider } from '@/components/crypto-provider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<CryptoProvider>
{children}
</CryptoProvider>
</body>
</html>
);
}2. Middleware Pattern for Auth
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Note: Middleware runs on Edge Runtime, crypto operations must happen client-side
export function middleware(request: NextRequest) {
// Check for auth cookie (set by client after decryption)
const isAuthenticated = request.cookies.get('auth-status')?.value === 'true';
if (!isAuthenticated && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
};
// app/hooks/use-auth-sync.ts
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
export function useAuthSync() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => {
// Sync auth state to cookie for middleware
document.cookie = `auth-status=${isAuthenticated}; path=/; SameSite=Lax`;
}, [isAuthenticated]);
}3. Async Store Pattern with Suspense
// app/stores/async-user-store.ts
import { createAsyncStore } from 'zustand-crypto-persist/react';
import { createIndexedDBCryptoStore } from 'zustand-crypto-persist';
interface UserState {
user: User | null;
isLoading: boolean;
error: Error | null;
fetchUser: () => Promise<void>;
}
export const useAsyncUserStore = createIndexedDBCryptoStore<UserState>(
'async-user-store',
process.env.NEXT_PUBLIC_CRYPTO_KEY!,
createAsyncStore((set, get) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/user');
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({ error: error as Error, isLoading: false });
}
},
}))
);
// app/components/user-dashboard.tsx
'use client';
import { Suspense } from 'react';
import { useSuspenseStore } from 'zustand-crypto-persist/react';
import { useAsyncUserStore } from '@/stores/async-user-store';
function UserContent() {
const user = useSuspenseStore(useAsyncUserStore, (state) => state.user);
if (!user) {
return <div>No user data available</div>;
}
return (
<div>
<h1>Welcome, {user.name}!</h1>
{/* User dashboard content */}
</div>
);
}
export function UserDashboard() {
return (
<Suspense fallback={<UserDashboardSkeleton />}>
<UserContent />
</Suspense>
);
}
function UserDashboardSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}Handling Hydration Mismatches
1. Client-Only Wrapper
// app/components/client-only.tsx
'use client';
import { useEffect, useState } from 'react';
export function ClientOnly({ children }: { children: React.ReactNode }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
return <>{children}</>;
}
// Usage
import { ClientOnly } from '@/components/client-only';
import { UserData } from '@/components/user-data';
export function Page() {
return (
<ClientOnly>
<UserData /> {/* Component that uses encrypted store */}
</ClientOnly>
);
}2. Progressive Enhancement Pattern
// app/stores/settings-store.ts
import { createCryptoStore } from 'zustand-crypto-persist';
interface SettingsState {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
toggleNotifications: () => void;
}
// Default values that match server expectations
const defaultSettings: Partial<SettingsState> = {
theme: 'light',
language: 'en',
notifications: true,
};
export const useSettingsStore = createCryptoStore<SettingsState>(
{
name: 'settings-store',
storage: {
secretKey: process.env.NEXT_PUBLIC_CRYPTO_KEY!,
storageType: 'localStorage',
},
},
(set) => ({
...defaultSettings,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () => set((state) => ({
notifications: !state.notifications
})),
}),
{
skipHydration: true,
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('Failed to rehydrate settings:', error);
// Fall back to defaults on error
return defaultSettings;
}
},
}
);
// app/components/theme-provider.tsx
'use client';
import { useEffect, useState } from 'react';
import { useSettingsStore } from '@/stores/settings-store';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const theme = useSettingsStore((state) => state.theme);
useEffect(() => {
setMounted(true);
// Trigger rehydration after mount
useSettingsStore.persist.rehydrate();
}, []);
// Use default theme during SSR and initial mount
const currentTheme = mounted ? theme : 'light';
return (
<div data-theme={currentTheme}>
{children}
</div>
);
}Best Practices for SSR
- Always use
skipHydration: truefor encrypted stores to prevent hydration mismatches - Initialize crypto early in your app lifecycle (layout or _app component)
- Use client components for components that read from encrypted stores
- Provide fallback UI during the initialization phase
- Handle errors gracefully - crypto operations can fail in various scenarios
- Use progressive enhancement - start with default values, enhance after hydration
- Consider using cookies for critical auth state needed during SSR
- Test thoroughly in both development and production modes
Common Pitfalls and Solutions
Problem: "Hydration mismatch" errors
// ❌ Bad: Reading encrypted store during SSR
export default function Page() {
const user = useUserStore((state) => state.user);
return <div>{user?.name}</div>;
}
// ✅ Good: Client-only component with loading state
'use client';
export default function Page() {
const [isClient, setIsClient] = useState(false);
const user = useUserStore((state) => state.user);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}Problem: "Crypto is not defined" on server
// ❌ Bad: Top-level crypto operations
const encrypted = await encryptData('data', 'key'); // Fails on server
// ✅ Good: Crypto operations only in browser
export function EncryptButton() {
const handleEncrypt = async () => {
if (typeof window !== 'undefined') {
const encrypted = await encryptData('data', 'key');
// ... handle encrypted data
}
};
return <button onClick={handleEncrypt}>Encrypt</button>;
}Problem: Race conditions during initialization
// ✅ Good: Proper initialization sequence
export function useInitializedStore() {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const init = async () => {
await initializeCrypto();
await useUserStore.persist.rehydrate();
setIsReady(true);
};
init().catch(console.error);
}, []);
return isReady;
}Browser Support
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Full support (14+)
- Mobile browsers: Full support for modern versions
Requires browser support for:
- Web Crypto API
- IndexedDB (for IndexedDB storage)
- TextEncoder/TextDecoder
Examples
Check out the examples directory for:
- Next.js integration
- React Native (with polyfills)
- Migration examples
- Advanced patterns
Contributing
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
License
MIT © Vorshim92
Built with ❤️ using TypeScript and Web Crypto API
