If any of them look like this:
{"userId":42,"role":"admin","email":"user@example.com","plan":"pro"}
You have a problem.
Anyone who can access that browser — a shared computer, a browser extension, a shoulder-surfer, an XSS payload — can read everything you stored. No hacking required. It’s just… there.
Today I’m going to show you how to fix it in under 5 minutes using js-cookie-encrypt — the only actively maintained, zero-dependency, client-side encrypted cookie library built on the browser’s native SubtleCrypto API.
The Problem With Cookies Today
Browser cookies are the backbone of web sessions. Nearly every framework uses them to track authentication state, user preferences, feature flags, and shopping carts. They’re fast, they work across tabs, they survive page reloads.
But they have one glaring flaw: they’re stored in plaintext by default.
The most popular cookie library, js-cookie, has 23 million weekly downloads. It’s excellent. But it does zero encryption. Same story for universal-cookie (1.8M weekly downloads) and every other client-side cookie manager I’ve found.
The server-side world has secure-cookie and cookie-encrypter — but those are Express middleware. They don’t help you in a React SPA, a Next.js client component, or a Vue app.
crypto-js has encryption algorithms — but it’s been abandoned by its maintainers and carries 300KB+ of algorithms you’ll never use.
So developers are left with three bad options:
- Store plaintext (everyone does this)
- Roll their own encryption (error-prone, usually wrong)
- Use an abandoned library (crypto-js)
There’s a fourth option now.
Introducing js-cookie-encrypt
js-cookie-encrypt fills the gap that’s existed in the frontend ecosystem for years: a lightweight, actively maintained, client-side encrypted cookie library built on the browser’s native Web Cryptography API.
npm install js-cookie-encrypt
Here’s what your cookies look like after:
gcm:aGVsbG8td29ybGQtdGhpcy1pcy1lbmNyeXB0ZWQtd2l0aC1hZXMtZ2NtLTI1Ni1iaXQ...
Unreadable. Authenticated. Tamper-proof.
Why Native SubtleCrypto Instead of crypto-js?
Most encrypted cookie libraries reach for crypto-js. Don’t.
The browser has had a built-in cryptography API since 2013 — window.crypto.subtle. It:
- Ships in every modern browser with zero bundle cost
- Runs in a separate thread (non-blocking)
- Uses hardware acceleration where available
- Is maintained by browser vendors, not abandoned npm packages
- Implements AES-GCM with authenticated encryption (tamper detection built in)
js-cookie-encrypt uses SubtleCrypto directly. No crypto library dependency. Zero dependencies total.
Getting Started
Installation
npm install js-cookie-encrypt
# yarn add js-cookie-encrypt
# pnpm add js-cookie-encrypt
CDN:
<script src="https://cdn.jsdelivr.net/npm/js-cookie-encrypt/dist/js-cookie-encrypt.min.js"></script>
Basic Usage
import JsCookieEncrypt from 'js-cookie-encrypt';
const store = new JsCookieEncrypt({
storageKey: 'session',
cryptoConfig: {
privateKey: 'your-secret-key',
algorithm: 'aes-gcm',
}
});
// Write encrypted
await store.setAsync({
userId: 42,
role: 'admin',
email: 'user@example.com'
});
// Read decrypted
const session = await store.getAsync();
console.log(session?.role); // 'admin'
That’s it. Everything in the cookie is now AES-GCM 256-bit encrypted. The data in DevTools is an unreadable ciphertext blob.
TypeScript-First Design
Every API is fully generic. You get autocomplete, type checking, and compile-time errors — not just any.
interface UserSession {
userId: number;
role: 'admin' | 'user' | 'guest';
preferences: {
theme: 'dark' | 'light';
language: string;
};
}
const session = new JsCookieEncrypt<UserSession>({
storageKey: 'session',
cryptoConfig: { privateKey: 'secret', algorithm: 'aes-gcm' }
});
// TypeScript knows the shape of everything
const role = await session.getAsync('role'); // typed as 'admin' | 'user' | 'guest'
const theme = await session.getByPathAsync('preferences.theme'); // typed as 'dark' | 'light'
// This is a compile error — 'superadmin' is not valid
await session.setAsync({ role: 'superadmin' }); // ❌ Type error
The deep path API uses TypeScript’s template literal types to infer the exact return type at every dot-notation path. getByPathAsync('preferences.theme') returns 'dark' | 'light' — not any.
Deep Path Operations
Working with nested objects doesn’t require reading, cloning, and re-writing the entire cookie. The path API handles it:
interface AppState {
user: {
name: string;
address: { city: string; country: string };
preferences: { theme: 'dark' | 'light'; notifications: boolean };
};
cart: { items: number[]; total: number };
}
const store = new JsCookieEncrypt<AppState>({
storageKey: 'app',
cryptoConfig: { privateKey: 'secret', algorithm: 'aes-gcm' }
});
// Initialize
await store.setAsync({
user: { name: 'Alice', address: { city: 'London', country: 'UK' }, preferences: { theme: 'dark', notifications: true } },
cart: { items: [], total: 0 }
});
// Get nested value — typed as string
const city = await store.getByPathAsync('user.address.city');
// 'London'
// Update one nested field without touching the rest
await store.setByPathAsync('user.address.city', 'Paris');
// Deep merge a nested object
await store.updateByPathAsync('user.preferences', { theme: 'light' });
// Delete a nested field
await store.deleteByPathAsync('user.address.country');
// Check existence
const hasCity = await store.hasAsync('user.address.city'); // true
All of these read → decrypt → mutate → encrypt → write under the hood. You work with clean data.
Real-Time Change Subscriptions
Subscribe to cookie changes across your application. Perfect for keeping UI state in sync without prop drilling or a global store.
const unsubscribe = store.subscribe((event) => {
switch (event.type) {
case 'set':
console.log('Cookie created:', event.newValue);
break;
case 'update':
console.log('Changed:', event.oldValue, '→', event.newValue);
break;
case 'delete':
console.log('Fields deleted, cookie is now:', event.newValue);
break;
case 'clear':
console.log('Cookie cleared. Was:', event.oldValue);
break;
}
});
// Each method fires the correct event type
await store.setAsync({ items: [] }); // fires 'set'
await store.updateAsync({ items: [1, 2, 3] }); // fires 'update'
await store.deleteFieldsAsync(['cart']); // fires 'delete'
await store.clearAsync(); // fires 'clear'
// Clean up
unsubscribe();
Enterprise Key Rotation
Rotating encryption keys in production is painful when users have existing encrypted cookies — they break the moment you deploy a new key.
js-cookie-encrypt solves this with zero downtime key rotation. Pass an array of keys: the first is the active encryption key, the rest are fallbacks for decrypting old cookies.
const store = new JsCookieEncrypt({
storageKey: 'session',
cryptoConfig: {
// New key at index 0. Old keys at index 1, 2...
privateKey: ['new-key-2026', 'old-key-2025', 'older-key-2024'],
algorithm: 'aes-gcm',
}
});
// Automatically:
// 1. Tries to decrypt with 'new-key-2026'
// 2. Falls back to 'old-key-2025' if that fails
// 3. Falls back to 'older-key-2024' if that fails
// 4. Re-encrypts with 'new-key-2026' and saves
const session = await store.getAsync();
Users who have cookies encrypted with old keys get transparently migrated on their next request. No session invalidation. No support tickets.
SSR-Safe (Next.js, Nuxt, Remix)
The most common Next.js cookie bug: calling document.cookie on the server crashes with ReferenceError: document is not defined.
js-cookie-encrypt detects when document.cookie is unavailable and silently falls back to an in-memory Map. Your code works identically on server and client.
// lib/session.ts — safe to import anywhere in Next.js
import JsCookieEncrypt from 'js-cookie-encrypt';
interface Session {
userId: number;
role: string;
}
export const sessionStore = new JsCookieEncrypt<Session>({
storageKey: 'session',
cryptoConfig: {
privateKey: process.env.NEXT_PUBLIC_COOKIE_KEY!,
algorithm: 'aes-gcm',
},
defaultOptions: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
}
});
// app/page.tsx — works in server components too
import { sessionStore } from '@/lib/session';
export default async function Page() {
const session = await sessionStore.getAsync();
// session is null server-side (no document.cookie)
// session is populated client-side after hydration
}
React Hook Example
Here’s a production-ready React hook that keeps state in sync with the encrypted cookie:
import { useEffect, useState, useCallback } from 'react';
import JsCookieEncrypt from 'js-cookie-encrypt';
interface UserPrefs {
theme: 'dark' | 'light';
language: string;
notifications: boolean;
}
const prefStore = new JsCookieEncrypt<UserPrefs>({
storageKey: 'prefs',
cryptoConfig: { privateKey: 'secret', algorithm: 'aes-gcm' },
defaultOptions: { sameSite: 'lax', path: '/' }
});
export function usePreferences() {
const [prefs, setPrefs] = useState<UserPrefs | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
prefStore.getAsync().then(data => {
setPrefs(data as UserPrefs | null);
setLoading(false);
});
// Stay in sync with external changes
const unsubscribe = prefStore.subscribe(event => {
if (event.type === 'set' || event.type === 'update') {
setPrefs(event.newValue as UserPrefs);
}
if (event.type === 'clear') {
setPrefs(null);
}
});
return unsubscribe;
}, []);
const update = useCallback(
(updates: Partial<UserPrefs>) => prefStore.updateAsync(updates),
[]
);
const clear = useCallback(() => prefStore.clearAsync(), []);
return { prefs, loading, update, clear };
}
// In your component
function SettingsPage() {
const { prefs, loading, update } = usePreferences();
if (loading) return <Spinner />;
return (
<button onClick={() => update({ theme: prefs?.theme === 'dark' ? 'light' : 'dark' })}>
Toggle Theme (currently: {prefs?.theme})
</button>
);
}
How the Encryption Actually Works
For the curious — here’s what happens under the hood when you call setAsync():
Encryption:
- Your data object is serialized to JSON:
{"userId":42,"role":"admin"} - A random 12-byte IV (initialization vector) is generated using
crypto.getRandomValues() - Your private key is hashed with SHA-256 to produce a consistent 256-bit AES key
- The JSON string is encrypted using AES-GCM with the IV
- The IV (12 bytes) is prepended to the ciphertext
- The combined bytes are base64-encoded and prefixed with
gcm: - The result is written to
document.cookie
Decryption:
- The cookie is read and the
gcm:prefix stripped - The base64 string is decoded back to bytes
- The first 12 bytes are extracted as the IV
- The remaining bytes are decrypted using AES-GCM (this also verifies the authentication tag — if the data was tampered with, decryption fails)
- The decrypted bytes are decoded from UTF-8 to a string
- The JSON string is parsed and returned as your typed object
AES-GCM is authenticated encryption — it doesn’t just encrypt, it also produces an authentication tag that detects any tampering with the ciphertext. If someone modifies your encrypted cookie, decryption throws rather than returning corrupted data.
Comparison With Alternatives
| js-cookie | universal-cookie | crypto-js | js-cookie-encrypt | |
|---|---|---|---|---|
| Browser cookies | ✅ | ✅ | ❌ | ✅ |
| AES-GCM 256-bit | ❌ | ❌ | ✅ | ✅ |
| Native Web Crypto | ❌ | ❌ | ❌ | ✅ |
| Zero dependencies | ✅ | ❌ | ❌ | ✅ |
| TypeScript generics | ✅ | ✅ | ✅ | ✅ |
| Key rotation | ❌ | ❌ | ❌ | ✅ |
| Deep path API | ❌ | ❌ | ❌ | ✅ |
| Change events | ❌ | ❌ | ❌ | ✅ |
| SSR / Next.js safe | ⚠️ | ✅ | ❌ | ✅ |
| Actively maintained | ✅ | ✅ | ❌ abandoned | ✅ |
| Weekly downloads | 23M | 1.8M | 15M | growing |
Security Considerations (Be Honest With Your Users)
I want to be transparent about what this library does and doesn’t protect against.
What it protects:
- Casual reading of cookie values in DevTools
- Cookie values visible in log files, analytics tools, error trackers
- Network-level interception of cookie values (combined with
secure: true) - Shoulder surfing
- Automated scraping of cookie values
What it does NOT protect against:
- An attacker with JavaScript execution on your page. The encryption key is accessible to JS — if your site has XSS vulnerabilities, those need to be fixed first.
- Browser extensions with full page access
- Physical access to the machine (cookies are stored on disk)
This library is best described as defense in depth — it makes cookie values meaningless to anyone who isn’t running your application code. For sessions that need true server-side security, use HttpOnly cookies set by your server (no JS library can do this — it’s a server responsibility).
Production Configuration Checklist
const store = new JsCookieEncrypt({
storageKey: 'session',
cryptoConfig: {
privateKey: process.env.NEXT_PUBLIC_COOKIE_SECRET!, // ✅ env var, not hardcoded
algorithm: 'aes-gcm', // ✅ strong cipher
},
defaultOptions: {
secure: process.env.NODE_ENV === 'production', // ✅ HTTPS only in prod
sameSite: 'lax', // ✅ CSRF protection
path: '/', // ✅ available site-wide
// expires: 7 * 24 * 60 * 60 * 1000, // optional: 7 days in ms
}
});
Install and Try It Now
npm install js-cookie-encrypt
- GitHub
- npm
If you find it useful, a ⭐ on GitHub goes a long way. Issues and PRs welcome.
Wrapping Up
The frontend ecosystem has had a gap for years: no maintained, client-side, encrypted cookie library. Every option was either plaintext, abandoned, server-only, or required a 300KB dependency.
js-cookie-encrypt fills that gap. It’s:
- Built on native browser APIs (no dependency risk)
- AES-GCM 256-bit (authenticated encryption, not just obfuscation)
- TypeScript-first with full generic type inference
- Ready for production with key rotation and SSR support
Your users’ data deserves better than plaintext cookies. It takes five minutes to fix.
