Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Encrypting data in localStorage does not protect your JavaScript (or your users)

A pattern shows up in a lot of front-end code reviews: some piece of data — a token, a cached profile, a license flag, sometimes even a "secret" configuration — is written to localStorage, and when someone objects, the a

A pattern shows up in a lot of front-end code reviews: some piece of data — a token, a cached profile, a license flag, sometimes even a "secret" configuration — is written to localStorage, and when someone objects, the answer is "we'll encrypt it." The team imports a library, picks a password or a key baked into the bundle, and calls the problem solved.

It is not solved. In the browser, encryption without a key the attacker does not have is a delay, and often a short one. This article separates three different goals that get bundled under "encrypt localStorage," because they need different designs.

Three problems people think they are solving

  1. Hiding application logic. The JavaScript that implements a feature should not be easy to copy. localStorage is irrelevant to this. Your logic is already in the JS files the browser downloaded.
  2. Stopping a casual user from editing a value. For example, flipping plan: "free" to plan: "pro" in DevTools. Encryption can make that edit slightly harder if the value is also integrity-protected, but only if the key is not sitting next to the ciphertext, and only if the server still enforces the plan.
  3. Protecting data at rest from another program on the same machine. This is a real threat model (shared computers, malware, other extensions), and it is the one Web Crypto can actually help with — if, and only if, the key is not shipped in your bundle.

If you do not know which of these you mean, you will pick a library, wrap localStorage.setItem, and ship a harder-to-read JSON blob that still decrypts in the console in one call.

Where the key lives is the whole story

A typical "encrypted localStorage" helper looks like this:

const KEY = "s3cret-please-ignore"; // or CryptoJS.enc.Utf8.parse("...")

export function save(name, value) {
  const cipher = encrypt(JSON.stringify(value), KEY);
  localStorage.setItem(name, cipher);
}

export function load(name) {
  const cipher = localStorage.getItem(name);
  return JSON.parse(decrypt(cipher, KEY));
}

The ciphertext in Application → Local Storage looks impressive. The key is in the same origin's JavaScript, which the same person who opened DevTools can also read. From there the decrypt function is a copy-paste away.

Variations that fail the same way:

  • Key derived from a hard-coded salt and a hard-coded passphrase.
  • Key fetched from your own front-end config file or a "secret" environment variable that was inlined at build time (VITE_STORAGE_KEY and friends).
  • Key "hidden" by obfuscating the file that contains it. Obfuscation raises the cost of finding the key; it does not create a key the user does not have. Once the app runs, the key is in memory.

If your app can decrypt the value without the user entering a secret, so can anyone who controls that browser origin.

What Web Crypto is actually good for

The Web Crypto API (crypto.subtle) is a solid tool. Use it when the key is not a constant in your repo.

A model that makes sense: the user has a passphrase (or a platform authenticator), you derive a key with PBKDF2 or Argon2 (the latter via a library; Web Crypto provides PBKDF2), and you encrypt with AES-GCM so you get confidentiality and integrity.

const enc = new TextEncoder();

async function keyFromPassphrase(passphrase, salt) {
  const base = await crypto.subtle.importKey(
    "raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations: 310_000, hash: "SHA-256" },
    base,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

This protects data at rest against someone who has the disk or the localStorage dump but not the passphrase. It does not protect data from the user who knows the passphrase, from XSS on your origin (which can prompt or wait for the key to be used), or from your own application logic being read.

XSS is the uncomfortable part. Anything your page can decrypt, a injected script on the same origin can decrypt too. localStorage plus encryption is not a substitute for a strict CSP, HTTP-only cookies for session tokens, or not storing tokens in JavaScript-accessible storage in the first place.

License flags and "pro unlocked" values

This is the case that looks like a code-protection problem and is actually an authorization problem.

If the client reads localStorage.plan === "pro" and shows the paid UI, an encrypted value does not help. The user patches the decrypt result, or the branch that reads it, or they skip the check. The server has to be the source of truth for entitlements, and the client should treat a local cache as a cache.

What is a code-protection problem is the function that validates a license offline — a signature check, a domain lock, a short-lived offline grant. That function is source you may want to protect, because the failure mode is "patch this one if." Encrypting the string it writes to localStorage does not protect that function. Protecting the function (and still verifying on the server when a network is available) does.

VEILVM is a browser-based JSVMP workspace for that narrower job: you extract the sensitive function and compile it to a custom bytecode with a small interpreter, so a beautifier does not immediately reveal the check. It raises cost; it does not replace server-side authorization, and it does not make a baked-in storage key safe.

A practical policy

  • Session tokens: prefer HTTP-only, Secure, SameSite cookies. If you must keep a token in the browser, know that any script on your origin can read it; encryption with a bundled key adds almost nothing.
  • Cached user data: encrypt only when you have a user-held secret or a platform key, and document the threat you are addressing (at-rest on a shared machine, not "users cannot see their data").
  • Entitlements and prices: the server decides; the client may cache a signed blob it can verify, not a boolean it can flip.
  • Proprietary logic: protect the code, do not bury it in localStorage.

If you want to try VM protection on a small license or verification function, VEILVM runs in the browser with no sign-up: 3 free runs a day, up to 5 KB per file (resets at 00:00 UTC). A free account raises the input limit to 30 KB. Pro is $9/month or $79/year and adds an API for CI. Standard requests are processed in memory and are not written to logs. Start at https://vm.ldvmp.com.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.