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
-ecauses the script to exit immediately if any command fails, instead of continuing on with a broken state.-utreats the use of an undefined variable as an error, which catches typos and prevents scripts from operating on empty/unset values unexpectedly.-o pipefailmakes a pipeline return a failure status if any command in the pipeline fails, not just the last one — without this, a failed command early in a pipe chain can be silently swallowed.
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
- Always quote variables:
"$var", not$var. - Use
set -euo pipefailat the top of every script. - Validate and sanitize all external input with strict patterns.
- Avoid
evaland other constructs that execute dynamic strings. - Store secrets outside the script, in environment variables or permission-restricted files.
- Use full paths for commands in privileged scripts.
- Use
mktempfor any temporary file needs. - Run
shellcheckon every script as part of your workflow. - Limit privilege escalation to only the specific commands that need it.
Security Considerations Beyond the Script Itself
- File permissions matter as much as code. A perfectly written script with
chmod 777permissions can still be tampered with by any user on the system. - Audit third-party scripts before running them, especially anything downloaded via
curl | bash, which executes a remote script without ever letting you inspect it first. Download it, read it, then run it. - Log carefully. Avoid logging full command lines or environment dumps that might include secrets.
- Keep your system and Bash version updated, since shell vulnerabilities (like the historical Shellshock bug) do occasionally surface and get patched.
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
- Forgetting to quote variables, especially in
ifconditions and command arguments. - Using
evalfor convenience without considering the injection risk. - Hardcoding credentials directly in scripts committed to version control.
- Running entire scripts as root when only one or two commands actually require elevated privileges.
- Piping
curloutput directly intobashwithout reviewing the script first.
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.