Zustand localStorage Encryption: Protecting Sensitive Data with partialize and a Custom AES-GCM Adapter
Every frontend developer eventually faces this question: "Is it okay to just store this token in localStorage?" I used to blindly dump accessToken straight into localStorage without much thought — it wasn't until a code review called it out that I started taking this problem seriously. If you've used Zustand in React, you know how convenient the persist middleware is, but with default settings, even sensitive state ends up exposed in plain text.
This article assumes basic familiarity with Zustand. It walks through code showing how to combine two layers of defense: using partialize to limit what gets persisted, and a custom storage adapter to encrypt persisted data with AES-GCM.
By the end, you'll be able to write code that inserts an encryption layer without touching your existing stores. I'll also be upfront about the security limits of this approach — so it helps to first understand why "what gets saved" and "how it's saved" are controlled separately.
Core Concepts
Two Key Options in the persist Middleware
Zustand's persist automatically saves and restores store state to external storage (localStorage, sessionStorage, AsyncStorage, etc.). There are two options you need to know for handling sensitive data — the names are a bit unfamiliar at first, so they can be confusing.
partialize is a filter function that picks only the fields you actually want written to storage from the full state. Think of it as saying "save only these."
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
// UI state like isLoading, modalOpen won't be saved if not returned
})Note:
partializeonly determines the shape right before serialization. Regardless of subscription scope, excluding a field does not stop the persist middleware from reacting to all state changes.
A custom storage adapter is an object implementing three methods: getItem, setItem, and removeItem. Pass this adapter to Zustand's createJSONStorage() helper and it automatically wraps it with a JSON serialization/deserialization layer.
const myStorage: StateStorage = {
getItem: (name) => { /* decrypt and return */ },
setItem: (name, value) => { /* encrypt and save */ },
removeItem: (name) => { /* delete */ },
}Why Combine Both Layers
Using partialize alone still leaves saved values in plain text. Using only the adapter means paying encryption costs even for unnecessary UI state. Combining both options creates the following flow:
| Layer | Option | Role |
|---|---|---|
| Limit what's saved | partialize |
Keeps unnecessary UI state out of storage |
| Protect what's saved | Custom adapter | Only AES-GCM-encrypted strings are written to storage |
Zustand internally keeps state as plain text, while only encrypted values land on disk (storage). The store code doesn't need to know anything about encryption, and the encryption logic is fully encapsulated inside the adapter.
Practical Application
Example 1: Quick Implementation with CryptoJS
This is the most straightforward approach. Using the crypto-js library lets you implement it with synchronous code, so you don't have to worry about async handling. It's a great starting point when you want to get something working quickly.
pnpm add crypto-js
pnpm add -D @types/crypto-jsimport { create } from 'zustand'
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'
import CryptoJS from 'crypto-js'
// NEXT_PUBLIC_STORAGE_KEY가 없으면 런타임에 암호화가 빈 키로 동작합니다.
// non-null assertion(!)은 빌드 타임에 환경변수가 반드시 주입되는 경우를 전제합니다.
const SECRET_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY!
// 1. 암호화 어댑터
const encryptedStorage: StateStorage = {
getItem: (name) => {
const encrypted = localStorage.getItem(name)
if (!encrypted) return null
try {
const bytes = CryptoJS.AES.decrypt(encrypted, SECRET_KEY)
return bytes.toString(CryptoJS.enc.Utf8)
} catch {
return null // 손상된 데이터는 null 반환 → 초기 상태로 안전하게 복귀
}
},
setItem: (name, value) => {
const encrypted = CryptoJS.AES.encrypt(value, SECRET_KEY).toString()
localStorage.setItem(name, encrypted)
},
removeItem: (name) => localStorage.removeItem(name),
}
// 2. 스토어 정의
interface AuthState {
user: { id: string; name: string } | null
accessToken: string | null
isLoading: boolean // UI 상태
modalOpen: boolean // UI 상태
setUser: (user: AuthState['user'], token: string) => void
logout: () => void
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
isLoading: false,
modalOpen: false,
setUser: (user, accessToken) => set({ user, accessToken }),
logout: () => set({ user: null, accessToken: null }),
}),
{
name: 'auth-store',
storage: createJSONStorage(() => encryptedStorage),
// isLoading, modalOpen은 저장 대상에서 제외
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
}),
}
)
)| Code Point | Description |
|---|---|
try/catch in getItem |
Protects the app from crashing when storage data is corrupted or the key has changed |
Excluding setters from partialize |
Functions cannot be JSON-serialized — returning only state values is recommended |
NEXT_PUBLIC_ env variable |
Has the limitation of being included in the client bundle (see caveats below) |
crypto-js weighs in at around 43KB in bundle size. If you want to avoid external dependencies and keep your bundle lean, or if you're targeting modern browser environments, you can use the browser's built-in Web Crypto API directly. However, the API is asynchronous, which requires additional hydration handling.
Example 2: Eliminating External Dependencies with Web Crypto API
Using the browser's built-in window.crypto.subtle, you can implement AES-GCM 256-bit encryption without any external library. My first reaction was "wait, this is just built into the browser?" — but for modern browser environments, it's a perfectly practical choice.
import { create } from 'zustand'
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'
const SECRET_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY!
const ALGORITHM = { name: 'AES-GCM', length: 256 } as const
// 매번 새로 만들면 비효율적이라 캐싱
// 주의: 키는 모듈이 로드될 때 한 번 계산됩니다.
// 환경변수가 변경되어도(핫리로드 등) 재계산이 일어나지 않습니다.
let cachedKey: CryptoKey | null = null
async function deriveKey(secret: string): Promise<CryptoKey> {
if (cachedKey) return cachedKey
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(secret), 'PBKDF2', false, ['deriveKey']
)
// 솔트를 고정 문자열로 사용하면 PBKDF2의 레인보우 테이블 방어 효과가 약해집니다.
// 고보안 환경에서는 crypto.getRandomValues()로 생성한 솔트를
// localStorage에 별도 저장하는 방식을 고려하는 것을 권장합니다.
cachedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: enc.encode('zustand-persist-salt-v1'),
iterations: 100_000,
hash: 'SHA-256',
},
keyMaterial,
ALGORITHM,
false,
['encrypt', 'decrypt']
)
return cachedKey
}
async function encrypt(text: string): Promise<string> {
const key = await deriveKey(SECRET_KEY)
const iv = crypto.getRandomValues(new Uint8Array(12)) // AES-GCM은 12바이트 IV
const encoded = new TextEncoder().encode(text)
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
// IV + 암호문을 하나의 base64 문자열로 합쳐서 저장
const combined = new Uint8Array([...iv, ...new Uint8Array(ciphertext)])
// 스프레드로 String.fromCharCode에 큰 배열을 넘기면 콜스택 초과 위험이 있어 reduce 사용
const binaryString = combined.reduce((acc, byte) => acc + String.fromCharCode(byte), '')
return btoa(binaryString)
}
async function decrypt(data: string): Promise<string> {
const key = await deriveKey(SECRET_KEY)
const combined = Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
return new TextDecoder().decode(decrypted)
}
// 비동기 어댑터
const webCryptoStorage: StateStorage = {
getItem: async (name) => {
const val = localStorage.getItem(name)
if (!val) return null
try {
return await decrypt(val)
} catch {
return null
}
},
setItem: async (name, value) => {
localStorage.setItem(name, await encrypt(value))
},
removeItem: (name) => localStorage.removeItem(name),
}
// 하이드레이션 완료 여부 추적을 위해 인터페이스에 명시 필요
interface AuthState {
user: { id: string; name: string } | null
accessToken: string | null
_hasHydrated: boolean
setHasHydrated: (value: boolean) => void
setUser: (user: AuthState['user'], token: string) => void
logout: () => void
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
_hasHydrated: false,
setHasHydrated: (value) => set({ _hasHydrated: value }),
setUser: (user, accessToken) => set({ user, accessToken }),
logout: () => set({ user: null, accessToken: null }),
}),
{
name: 'auth-store-v2',
storage: createJSONStorage(() => webCryptoStorage),
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
}),
// onRehydrateStorage 내부 콜백의 state는 복원된 상태 스냅샷입니다.
// state?.setState(...)는 동작하지 않으며, 스토어에 정의한 액션을 호출해야 합니다.
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
}
)
)
// 컴포넌트에서 하이드레이션 대기
function AuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useAuthStore((s) => s._hasHydrated)
if (!hasHydrated) return <LoadingSpinner />
return <>{children}</>
}AES-GCM IV (Initialization Vector): A 12-byte value generated randomly on each encryption, ensuring that the same plaintext encrypted with the same key produces a different ciphertext every time. The IV is not a secret — it's fine to store it alongside the ciphertext.
Example 3: Separating Stores by Sensitivity
This is a pattern used quite often in production. Managing sensitive data that needs encryption and non-sensitive configuration data in separate stores can reduce both encryption costs and code complexity.
// encryptedStorage는 예시 1에서 정의한 CryptoJS 기반 어댑터입니다.
// 민감 데이터 전용 스토어 — 암호화 어댑터 사용
const useSecureStore = create<SecureState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
setTokens: (access, refresh) => set({ accessToken: access, refreshToken: refresh }),
clearTokens: () => set({ accessToken: null, refreshToken: null }),
}),
{
name: 'secure-store',
storage: createJSONStorage(() => encryptedStorage),
partialize: (s) => ({
accessToken: s.accessToken,
refreshToken: s.refreshToken,
}),
}
)
)
// 비민감 설정 스토어 — 일반 localStorage 사용
const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light' as const,
locale: 'ko',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'settings-store',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ theme: s.theme, locale: s.locale }),
}
)
)Pros and Cons
Advantages
| Item | Details |
|---|---|
| Separation of concerns | Encryption logic is encapsulated in the adapter; store code only deals with plain state |
| Selective persistence | partialize excludes transient UI state (loading, modals, etc.) from storage, preventing waste |
| Transparent application | The storage layer can be swapped without modifying existing store code |
| Bundle size savings | No external encryption library needed when using Web Crypto API |
| Type safety | Zustand 5.x's improved TypeScript inference validates partialize return types precisely |
Disadvantages and Caveats
Security-related
| Item | Details | Mitigation |
|---|---|---|
| Key exposure risk | NEXT_PUBLIC_ env variables are included in the client bundle and visible via DevTools |
Manage keys server-side only, or consider switching to HttpOnly cookies |
| XSS nullification | An attacker with access to the same browser context can obtain both the key and the data | Block XSS at the source with CSP headers (Content Security Policy) and input validation |
| PBKDF2 fixed-salt limitation | All users sharing the same salt weakens rainbow table defense | Consider generating a per-user random salt with crypto.getRandomValues() and storing it separately in localStorage |
Implementation-related
| Item | Details | Mitigation |
|---|---|---|
| Async hydration | Web Crypto API is async — state may be empty on the initial render | Handle loading with onRehydrateStorage and a _hasHydrated state |
| SSR environment | localStorage doesn't exist on the server, causing errors |
Use a typeof window !== 'undefined' guard or skipHydration() |
| Key rotation | Changing the encryption key requires re-encrypting or invalidating all existing data | Include a version in the store name (auth-store-v2) and increment it on key changes (a different version means a different storage key, so it starts fresh as an empty store) |
The nature of localStorage encryption: This pattern is closer to obfuscation than true security. Never store high-risk data like passwords or payment information in client-side storage. HttpOnly cookies or server-side sessions are the right choice for that. Think of this pattern as protection against "someone who briefly opened DevTools" or "an accidental shared-PC situation" — that's a realistic expectation for what it achieves.
The Most Common Mistakes in Practice
- Including functions (setters) in
partialize— Functions cannot be JSON-serialized. It's recommended to return only pure state values frompartialize. - Calling
state?.setState()inside theonRehydrateStoragecallback — Thestatein this callback is a snapshot of the restored state, not the store instance. The correct approach is to define an action likesetHasHydratedin the store and callstate?.setHasHydrated(true). Using the wrong pattern means hydration status will never becometrue. - Skipping hydration handling when using an async adapter — When
getItemreturns a Promise, there's a window during the initial render where state is empty. If not handled, this causes a flash where the logged-in state briefly flickers.
Closing Thoughts
Once you apply this pattern, you'll notice a tangible difference. Opening DevTools and checking Application → LocalStorage will show only encrypted strings, and you'll get fewer code review comments asking "wait, you're storing the token in plain text?" The key insight is that you can insert an encryption layer without touching your existing stores. The two roles — partialize deciding what gets saved, and the custom adapter protecting how it's saved — are independent, so they can be combined or swapped as needed.
Three steps you can try right now:
- Open an existing store right now and use
partializeto exclude transient UI state likeisLoadinganderrorfrom being persisted. A single line of code can reduce storage waste and clarify your state structure. - Ready to apply in your next PR: implement the CryptoJS-based encryption adapter. Run
pnpm add crypto-js, copy the Example 1 code, wire up yourSECRET_KEYfrom an environment variable, and you have a working encrypted store. - If bundle size is a concern, switch to the Web Crypto API version. Extract the
deriveKey+encrypt/decryptfunctions from Example 2 into a separate utility file, add hydration handling withonRehydrateStorage, and you get the same level of protection with zero external dependencies.
References
- Persisting Store Data | Zustand Official Docs
- persist Middleware Reference | Zustand Official Docs
- How to Securely Persist User Data in Zustand with Encrypted Storage | Wiscaksono
- Encrypted localStorage with Zustand | DEV Community
- Are you exposing your localStorage data? | Hashnode
- Securing Web Storage: Best Practices | DEV Community
- zustand-mmkv-storage: Blazing Fast Persistence for React Native | DEV Community
- DeepWiki — persist Middleware Internals