Security Patterns for CLI Tools That Handle Credentials
CLI tools that interact with APIs, databases, or cloud services inevitably handle secrets. API keys, tokens, passwords, connection strings — all flowing through your tool. One careless decision and those secrets end up in shell history, log files, environment variable dumps, or npm packages.
This article covers the security patterns every CLI tool author needs to know — from credential storage to safe logging to supply chain protection.
1. Never Accept Secrets as Command-Line Arguments
# DANGEROUS: visible in shell history, ps output, process lists
mytool deploy --api-key sk_live_abc123
Command-line arguments are visible to every user on the system via ps aux. They're stored in shell history files. They appear in CI logs.
Instead, accept secrets through:
// Priority order for secret resolution
function getApiKey(options: CliOptions): string {
// 1. Environment variable (best for CI)
if (process.env.MYTOOL_API_KEY) {
return process.env.MYTOOL_API_KEY;
}
// 2. Config file (best for local development)
const config = loadConfig();
if (config.apiKey) {
return config.apiKey;
}
// 3. Interactive prompt (fallback)
if (process.stdin.isTTY) {
return promptForSecret('Enter API key:');
}
throw new CliError('API key required', {
suggestion: 'Set MYTOOL_API_KEY environment variable or run `mytool auth login`',
});
}
2. Store Credentials Securely
import { writeFile, readFile, mkdir, chmod, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_DIR = join(homedir(), '.mytool');
const CREDS_FILE = join(CONFIG_DIR, 'credentials.json');
export async function saveCredentials(creds: Record<string, string>) {
await mkdir(CONFIG_DIR, { recursive: true });
// Set directory permissions: owner only
await chmod(CONFIG_DIR, 0o700);
await writeFile(CREDS_FILE, JSON.stringify(creds, null, 2));
// Set file permissions: owner read/write only
await chmod(CREDS_FILE, 0o600);
}
export async function loadCredentials(): Promise<Record<string, string>> {
try {
// Verify permissions before reading
const stats = await stat(CREDS_FILE);
const mode = stats.mode & 0o777;
if (mode !== 0o600) {
console.error(
`Warning: ${CREDS_FILE} has permissions ${mode.toString(8)}, expected 600`
);
}
return JSON.parse(await readFile(CREDS_FILE, 'utf-8'));
} catch {
return {};
}
}
The chmod 600 pattern matches what SSH, AWS CLI, and kubectl use. It ensures only the file owner can read credentials.
3. Redact Secrets from Output
Never log secrets, even in verbose/debug mode:
function redact(value: string): string {
if (value.length <= 8) return '***';
return value.slice(0, 4) + '***' + value.slice(-4);
}
function redactObject(obj: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = ['key', 'token', 'secret', 'password', 'auth', 'credential'];
const result = { ...obj };
for (const [key, value] of Object.entries(result)) {
if (typeof value === 'string' && sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
result[key] = redact(value);
}
}
return result;
}
// Usage in verbose logging
debug(`Config: ${JSON.stringify(redactObject(config))}`);
// Output: Config: {"apiKey":"sk_l***c123","endpoint":"https://api.example.com"}
4. Secure Interactive Prompts
Hide input when prompting for secrets:
import { createInterface } from 'node:readline';
function promptForSecret(message: string): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stderr, // Use stderr so stdout stays clean
});
// Disable echo
process.stdin.setRawMode?.(true);
process.stderr.write(message + ' ');
let input = '';
process.stdin.on('data', (char) => {
const c = char.toString();
if (c === '\n' || c === '\r') {
process.stdin.setRawMode?.(false);
process.stderr.write('\n');
rl.close();
resolve(input);
} else if (c === '\u007f') { // Backspace
input = input.slice(0, -1);
} else {
input += c;
}
});
});
}
5. Protect Against Supply Chain Attacks
Your CLI runs on users' machines with their permissions. Protect them:
Lock Dependencies
{
"scripts": {
"prepublishOnly": "npm audit --production && npm run build"
}
}
Declare Minimal Permissions
Use engines and os fields to be explicit about what your tool needs:
{
"engines": { "node": ">=18" },
"os": ["darwin", "linux", "win32"]
}
Never Run postinstall Scripts for CLI Tools
Avoid postinstall scripts — they execute automatically on npm install and are the primary vector for supply chain attacks. If you need setup, use prepare (only runs on npm install from source) or provide an explicit mytool init command.
6. Validate and Sanitize File Paths
CLI tools often accept file paths as arguments. Protect against path traversal:
import { resolve, relative } from 'node:path';
function safePath(inputPath: string, allowedRoot?: string): string {
const resolved = resolve(inputPath);
if (allowedRoot) {
const rel = relative(allowedRoot, resolved);
if (rel.startsWith('..') || resolve(rel) === resolved) {
throw new CliError(`Path "${inputPath}" is outside allowed directory`, {
suggestion: `Paths must be within ${allowedRoot}`,
});
}
}
return resolved;
}
7. Token Expiry and Rotation
interface StoredCredential {
value: string;
createdAt: string;
expiresAt?: string;
}
export async function getValidToken(service: string): Promise<string> {
const cred = await loadCredential(service);
if (!cred) {
throw new CliError(`No credentials for ${service}`, {
suggestion: `Run: mytool auth login ${service}`,
});
}
if (cred.expiresAt && new Date(cred.expiresAt) < new Date()) {
throw new CliError(`Token for ${service} has expired`, {
suggestion: `Run: mytool auth refresh ${service}`,
});
}
// Warn if expiring soon (within 24 hours)
if (cred.expiresAt) {
const hoursLeft = (new Date(cred.expiresAt).getTime() - Date.now()) / 3600000;
if (hoursLeft < 24) {
console.error(chalk.yellow(` Warning: ${service} token expires in ${Math.round(hoursLeft)} hours`));
}
}
return cred.value;
}
8. The .npmignore Security Checklist
Never publish these to npm:
# .npmignore
.env
.env.*
*.pem
*.key
credentials.json
secrets/
test/
.github/
.vscode/
tsconfig.json
Or better, use the files whitelist in package.json:
{
"files": ["dist", "bin", "README.md", "LICENSE"]
}
Always run npm pack --dry-run before publishing to verify no secrets leak.
Security Checklist
- [ ] Secrets accepted via env vars or config files, never CLI args
- [ ] Credential files stored with 600 permissions
- [ ] Secrets redacted from all output including verbose/debug
- [ ] Interactive prompts hide secret input
- [ ] Dependencies audited before publish
- [ ] No
postinstallscripts - [ ] File paths validated against traversal
- [ ] Token expiry checked and warned
- [ ]
npm pack --dry-runverified before every publish - [ ]
.npmignoreorfilesfield prevents secret leakage
Conclusion
Security in CLI tools isn't optional — your tool runs with the user's full permissions. Every credential it touches, every file it reads, every dependency it includes is a potential attack surface. These patterns aren't paranoid — they're the baseline that tools like the AWS CLI, GitHub CLI, and Stripe CLI follow.
Wilson Xu builds secure developer tools. Find his 11+ packages at npm and follow at dev.to/chengyixu.
Top comments (0)