Skip to main content
interlace
Plugin: node-securityRules

require-secure-credential-storage

This rule detects a credential written to localStorage, sessionStorage, AsyncStorage or process.env without encryption

Enforces secure storage patterns for credentials

Severity: 🔴 CRITICAL
CWE: CWE-312: Cleartext Storage of Sensitive Information
OWASP Mobile: M1: Improper Credential Usage

Rule Details

This rule detects when a credential is stored via setItem() on client persistent storage — localStorage, sessionStorage, React Native's AsyncStorage — or assigned into process.env, without encryption. Insecure credential storage (plaintext, weak encryption) leads to credential theft if the device is compromised, local storage is read, or the environment is inherited by a child process.

fs.writeFile() is not this rule; disk writes belong to require-storage-encryption.

Why This Matters

Stored credentials must be encrypted to prevent theft:

  • Device theft: Attackers access unencrypted storage on stolen devices
  • Malware: Keyloggers or storage scanners extract plaintext credentials
  • Forensics: Deleted plaintext files can be recovered
  • Compliance: GDPR/PCI-DSS require encryption for stored credentials

❌ Incorrect

// Plaintext localStorage (browser)
localStorage.setItem('authToken', user.token); // ❌ Unencrypted

// Plaintext file storage (Node.js)
import fs from 'fs';
fs.writeFile(
  'credentials.json',
  JSON.stringify({
    username: user.username,
    password: user.password, // ❌ Plaintext password!
  }),
);

// Base64 encoding (NOT encryption!)
const encoded = btoa(JSON.stringify(credentials));
localStorage.setItem('creds', encoded); // ❌ Still plaintext, just encoded

// Weak "encryption" with reversible encoding
const obfuscated = rot13(password);
fs.writeFileSync('pass.txt', obfuscated); // ❌ Trivially reversible

✅ Correct

const x = 42;

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Encryption via Wrapper Functions

Why: We only detect a direct setItem() call whose VALUE argument is an encryption call. If encryption happens inside a wrapper function, we cannot verify it.

// ❌ NOT DETECTED - Wrapper may or may not encrypt
function saveCredentials(creds: Credentials) {
  localStorage.setItem('creds', JSON.stringify(creds)); // Actually unencrypted!
}
saveCredentials({ username, password });

Mitigation: Document encryption requirements for wrapper functions. Use TypeScript branded types for encrypted data.

Weak or Broken Encryption

Why: We only check for the presence of encrypt() in the call chain. We can't verify encryption strength.

// ❌ NOT DETECTED - Weak encryption
const weakEncrypted = xorEncrypt(password, 'key'); // XOR is broken
localStorage.setItem('pass', weakEncrypted);

Mitigation: Use vetted encryption libraries (SubtleCrypto, Node crypto). Enforce AES-256-GCM minimum.

IndexedDB

Why: localStorage, sessionStorage and AsyncStorage are analyzed. IndexedDB is not.

// ❌ NOT DETECTED - IndexedDB
store.put({ id: 1, token: authToken }); // Still unencrypted!

Mitigation: Apply the encryption requirement to all persistent storage APIs.

⚙️ Configuration

This rule has no configuration options. It requires encrypt() wrapper for all setItem() and writeFile() calls.

📚 References

The Node sink: process.env

// ❌ CWE-526 — a credential assigned into the environment
process.env.SESSION_TOKEN = sessionToken;
process.env.NPM_TOKEN = await vault.read('npm/token');

Everything the rule used to check — localStorage, sessionStorage, AsyncStorage — is a browser or React Native global. None of them exists in Node, so on a pure server codebase this rule had no reachable sink at all: it was a browser rule filed under Node, quiet on every project it ran against.

Disk writes could not simply be added here; require-storage-encryption owns those, and duplicating them recreates the double-reporting defect the two rules were split apart to fix. process.env is the sink neither rule claimed, and it is the one Node actually has.

Why the assignment is a finding:

  • every child process the app spawns inherits the value;
  • it is readable at /proc/<pid>/environ by anything running as the same user;
  • crash dumps and the environment snapshots error reporters upload capture it verbatim.

Reading process.env.TOKEN is fine and universal — only the write reports.

Not a finding

This rule owns client persistent storage — localStorage, sessionStorage and React Native's AsyncStorage, all of which keep what you give them in the clear — plus the Node environment. Writes to disk belong to require-storage-encryption.

CodeWhy it is silent
localStorage.setItem('theme', 'dark')A store, but nothing says a credential is going into it.
localStorage.setItem('authToken', encrypt(token))Encrypted on the way in.
cache.setItem('password', pwd)setItem on something that is not a persistent store.
localStorage.setItem('key', publicKey)key alone is not evidence — it matches keyboard, keyCode, objectKey.
process.env.PORT = '3000'The overwhelmingly common use of this assignment. No credential named.
const t = process.env.AUTH_TOKENA read, not a write.
process.env.API_TOKEN = encrypt(raw)Encrypted on the way in.
process.env[dynamicKey] = vA computed key with no readable name is no evidence.

If it fires, the key or the value named a credential. Note that an encrypt-looking variable is not proof: setItem('authToken', encrypted) still reports, because nothing in the file shows anything encrypted it. Wrap the value in the encryption call and the rule goes quiet.

Use this rule

npm install -D oxlint-plugin-node-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-node-security"],  "rules": {    "node-security/require-secure-credential-storage": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-node-security'],    rules: {      'node-security/require-secure-credential-storage': 'error',    },  },});

oxlint-plugin-node-security re-exports eslint-plugin-node-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.