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
  • -e causes the script to exit immediately if any command fails, instead of continuing on with a broken state.
  • -u treats the use of an undefined variable as an error, which catches typos and prevents scripts from operating on empty/unset values unexpectedly.
  • -o pipefail makes 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 pipefail at the top of every script.
  • Validate and sanitize all external input with strict patterns.
  • Avoid eval and 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 mktemp for any temporary file needs.
  • Run shellcheck on 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 777 permissions 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 if conditions and command arguments.
  • Using eval for 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 curl output directly into bash without 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.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Parse Command Line Arguments in Bash

How to Parse Command Line Arguments in Bash

Next Post
How to Set Environment Variables in Bash

How to Set Environment Variables in Bash

Related Posts