DEV Community

Wilson Xu
Wilson Xu

Posted on

Security Patterns for CLI Tools That Handle Credentials

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
Enter fullscreen mode Exit fullscreen mode

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`',
  });
}
Enter fullscreen mode Exit fullscreen mode

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 {};
  }
}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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;
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Declare Minimal Permissions

Use engines and os fields to be explicit about what your tool needs:

{
  "engines": { "node": ">=18" },
  "os": ["darwin", "linux", "win32"]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

8. The .npmignore Security Checklist

Never publish these to npm:

# .npmignore
.env
.env.*
*.pem
*.key
credentials.json
secrets/
test/
.github/
.vscode/
tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Or better, use the files whitelist in package.json:

{
  "files": ["dist", "bin", "README.md", "LICENSE"]
}
Enter fullscreen mode Exit fullscreen mode

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 postinstall scripts
  • [ ] File paths validated against traversal
  • [ ] Token expiry checked and warned
  • [ ] npm pack --dry-run verified before every publish
  • [ ] .npmignore or files field 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)