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.
🔗 Related Rules
no-hardcoded-credentials- Prevent hardcoded passwordsrequire-storage-encryption- General storage encryption
📚 References
- CWE-312: Cleartext Storage
- OWASP Mobile M1: Improper Credential Usage
- Web Crypto API
- Node.js Crypto Module
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>/environby 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.
| Code | Why 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_TOKEN | A read, not a write. |
process.env.API_TOKEN = encrypt(raw) | Encrypted on the way in. |
process.env[dynamicKey] = v | A 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{ "jsPlugins": ["oxlint-plugin-node-security"], "rules": { "node-security/require-secure-credential-storage": "error" }}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.