How to Secure Your Bash Scripts

How to Secure Your Bash Scripts

How to Secure Your Bash Scripts

I once inherited a deployment script that ran as root, accepted unvalidated user input, and passed it straight into a shell command. It worked fine for months — right up until someone entered a filename with a semicolon in it and accidentally deleted half a directory. That experience taught me that Bash scripting security isn’t an advanced, optional topic — it’s something every script author needs to think about from the very first line of code.

This guide walks through the practical, real-world techniques for writing Bash scripts that don’t become the weak link in your system’s security.

Why Bash Script Security Matters

Bash scripts often run with elevated privileges, handle sensitive data like credentials, and process external input (files, arguments, network responses). A single unquoted variable or unvalidated input can lead to command injection, accidental data loss, or privilege escalation. Unlike a compiled language with strict typing, Bash treats almost everything as text, which makes it deceptively easy to introduce vulnerabilities without realizing it.

Always Quote Your Variables

This is the single most important habit in secure Bash scripting. Unquoted variables undergo word splitting and globbing, which can lead to unexpected — and sometimes dangerous — behavior.

# Dangerous
filename=$1
rm $filename

If $1 is something like report.txt; rm -rf ~, and word splitting isn’t your concern here (since rm doesn’t invoke a shell), the bigger risk is a filename containing spaces or wildcards causing rm to delete unintended files.

# Safer
filename="$1"
rm -- "$filename"

Quoting "$filename" prevents word splitting and globbing. The -- tells rm that no more flags follow, which stops a filename like -rf from being interpreted as an option instead of a filename.

Never Use eval with Untrusted Input

eval executes a string as if it were typed directly into the shell, which makes it one of the most dangerous constructs in Bash when combined with any external input.

# Extremely dangerous
user_input="$1"
eval "echo $user_input"

If user_input is hello; rm -rf ~, this executes both commands. Avoid eval almost entirely. If you think you need it, there’s almost always a safer alternative — arrays, functions, or parameter expansion can usually achieve the same goal without invoking arbitrary code execution.

Validate and Sanitize All External Input

Any value coming from a user, a file, an API response, or a command-line argument should be treated as untrusted until validated.

read -p "Enter a filename: " filename

if [[ ! "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
  echo "Error: invalid filename." >&2
  exit 1
fi

This regex check (^[a-zA-Z0-9._-]+$) only allows letters, numbers, dots, underscores, and hyphens — rejecting anything that could be interpreted as a shell metacharacter (semicolons, pipes, backticks, etc.).

Use set -euo pipefail at the Top of Every Script

This one line dramatically reduces the chance of silent failures cascading into security issues:

#!/bin/bash
set -euo pipefail

Avoid Hardcoding Secrets

Never write API keys, passwords, or tokens directly into a script:

# Bad
API_KEY="sk_live_abc123456789"

Instead, load secrets from environment variables or a restricted-permission file that isn’t committed to version control:

API_KEY="${API_KEY:?Error: API_KEY environment variable not set}"

The ${VAR:?message} syntax causes the script to exit with an error message if the variable isn’t set, which is a good safeguard against accidentally running with missing configuration.

Set Restrictive File Permissions

Scripts that handle sensitive operations shouldn’t be world-readable or world-writable:

chmod 700 deploy.sh

This restricts the script so only its owner can read, write, or execute it — preventing other users on a shared system from reading sensitive logic or tampering with the script itself.

For files containing secrets:

chmod 600 .env

Use Full Paths for Commands in Sensitive Scripts

If your script runs with elevated privileges (root or sudo), relying on $PATH to find commands is risky — a malicious actor could manipulate $PATH to point to a fake rm or cp binary. Use full paths instead:

/bin/rm -f /tmp/tempfile
/usr/bin/cp source.txt dest.txt

You can find the full path of any command with:

which rm

Use mktemp for Temporary Files

Never manually construct temporary file paths like /tmp/tempfile123 — they’re predictable and can be exploited in symlink attacks, where an attacker pre-creates a file or symlink at that path to redirect your script’s output.

tempfile=$(mktemp)
echo "some data" > "$tempfile"
# ... use the file ...
rm -f "$tempfile"

mktemp creates a uniquely named, securely permissioned temporary file, closing off this entire class of vulnerability.

Avoid Running Scripts as Root Unless Absolutely Necessary

If a script doesn’t need root privileges for every operation, don’t run the whole thing as root. Instead, use sudo selectively for just the specific commands that need elevated access:

#!/bin/bash
set -euo pipefail

echo "Running as: $(whoami)"
sudo systemctl restart myapp

This way, if there’s a bug elsewhere in the script, the blast radius is limited to what a normal user can do, rather than what root can do.

Use shellcheck to Catch Issues Automatically

shellcheck is a static analysis tool that catches a huge range of common Bash mistakes, many of which have security implications (unquoted variables, unsafe use of eval, word-splitting bugs).

shellcheck deploy.sh

Running this on every script before deployment is one of the highest-leverage habits you can build. It’s saved me from shipping bugs I wouldn’t have caught through manual review alone.

Real-World Use Cases

1. Deployment scripts. Scripts that SSH into servers and restart services need careful handling of credentials (SSH keys, API tokens) and should never log sensitive output.

2. User-facing CLI tools. Any script that accepts filenames or arguments directly from users needs strict input validation to avoid path traversal or command injection.

3. Cron jobs running as root. Scheduled scripts that run with elevated privileges are a common attack vector if their file permissions are too loose or their logic accepts unvalidated input from a shared directory.

4. CI/CD pipelines. Scripts running in CI often have access to deployment credentials; a script with a command injection vulnerability could leak these secrets to an attacker who controls a pull request’s contents.

Best Practices Summary

Security Considerations Beyond the Script Itself

Troubleshooting Common Issues

Script behaves differently when run by cron vs. interactively: Cron runs with a minimal environment and often a different $PATH. Use full paths and explicitly set any required environment variables inside the script rather than relying on your interactive shell’s configuration.

set -u breaks an existing script: This usually reveals variables that were being used before being properly initialized — a good sign you’ve found a latent bug, not a false alarm.

shellcheck flags something you don’t understand: Each warning includes a reference code (like SC2086) that links to a detailed explanation on the ShellCheck wiki — always worth reading rather than suppressing blindly.

Common Mistakes to Avoid

FAQs

Q: Is Bash inherently insecure? No — Bash itself is fine. Most security issues come from how scripts handle external input, not from the language itself. Careful quoting, validation, and avoiding dangerous constructs like eval address the vast majority of risks.

Q: What is the single most impactful security habit for Bash scripting? Quoting variables consistently. It single-handedly prevents a huge share of common vulnerabilities related to word splitting and unintended globbing.

Q: Should I ever use curl | bash? Avoid it when possible. Download the script first, review its contents, and then execute it locally so you know exactly what you’re running.

Q: How do I store secrets safely for use in scripts? Use environment variables sourced from a permission-restricted .env file (not committed to version control), or better yet, a dedicated secrets manager for production systems.

Q: What does shellcheck actually catch that manual review might miss? Subtle issues like unquoted variables in specific contexts, incorrect use of [ vs [[, and unsafe patterns with eval or word splitting — the kind of details that are easy to overlook even for experienced script authors.

Summary

Securing Bash scripts comes down to a handful of consistent habits: quote everything, validate all external input, avoid dangerous constructs like eval, keep secrets out of your code, and lean on tools like shellcheck to catch what manual review misses. None of these practices are complicated on their own — the key is applying them consistently, every time, especially in scripts that run with elevated privileges or process untrusted input.

References

Exit mobile version