Bash Security Best Practices
Write secure Bash scripts — safe temp files with mktemp, avoiding command injection, handling secrets, and setting file permissions.
Safe Temporary Files
Scripts that create temporary files are vulnerable to symlink attacks if they use predictable names. An attacker who can write to /tmp can create a symlink at the expected path before your script does, redirecting your writes to an arbitrary file. mktemp eliminates this by creating files with randomly generated names using the O_EXCL flag — the creation fails if any file already exists at that path.
# Bad — predictable name, race condition, vulnerable to symlink attack
TMP="/tmp/myscript_temp.txt"
echo "data" > "$TMP"
# Good — mktemp creates a unique file atomically
TMP=$(mktemp)
echo "data" > "$TMP"
# With a descriptive prefix (the X's are replaced with random characters)
TMP=$(mktemp /tmp/myapp.XXXXXX)
# Temp directory
TMPDIR=$(mktemp -d)
TMPDIR=$(mktemp -d /tmp/myapp.XXXXXX)
# Always clean up with a trap — runs on any exit
cleanup() {
rm -f "$TMP"
rm -rf "$TMPDIR"
}
trap cleanup EXIT
mktemp creates files with mode 0600 (owner read/write only) by default, protecting them from other users on a shared system.
Avoiding Command Injection
Command injection happens when unsanitized external input reaches a shell-interpreted context. It is the Bash equivalent of SQL injection — the attacker’s input is treated as code rather than data. The fix is always the same: quote everything, use arrays for commands, and never use eval with untrusted input.
# VULNERABLE — if $filename contains "; rm -rf /", that gets executed
filename="$1"
ls $filename # word splitting and glob expansion on unquoted variable
eval "ls $filename" # catastrophically dangerous — eval executes arbitrary code
# SAFE — quoting prevents word splitting and special character interpretation
ls "$filename" # the shell cannot break this apart
# SAFE — use arrays for commands with arguments that come from variables
cmd=(ls -la "$filename")
"${cmd[@]}"
# VULNERABLE — user input injected into a grep pattern
user_input="$1"
result=$(grep $user_input /etc/passwd) # user can inject grep flags
# SAFE — use -- to signal end of options, treating input as a literal pattern
result=$(grep -- "$user_input" /etc/passwd)
# SAFE — validate input first with an allowlist
if [[ "$user_input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
grep -- "$user_input" /etc/passwd
fi
Never use eval with user-controlled data:
# DANGEROUS — if $user_input is '$(rm -rf ~)', that command executes
eval "echo $user_input"
# SAFE alternatives — no eval needed
printf '%s\n' "$user_input" # printf never evaluates its arguments
echo "$user_input" # safe when properly quoted
Validating Input
Input validation is the first line of defense. Validate all external inputs — command-line arguments, environment variables, file contents, and API responses — before using them. Fail fast with a clear error message rather than letting bad input propagate into your logic.
# Validate argument count before doing anything else
[[ $# -eq 2 ]] || { echo "Usage: $0 <src> <dst>" >&2; exit 2; }
# Validate a file path to prevent directory traversal attacks
validate_path() {
local path="$1"
local base_dir="$2"
# Resolve the canonical real path (no ../ tricks)
real_path=$(realpath -m "$path")
# Reject any path that escapes the allowed directory
if [[ "$real_path" != "$base_dir"/* ]]; then
echo "ERROR: path traversal detected: $path" >&2
return 1
fi
}
validate_path "$user_file" "/var/www/uploads"
# Validate a port number is actually a valid port
validate_port() {
local port="$1"
if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "ERROR: invalid port: $port" >&2
return 1
fi
}
# Allowlist validation — only accept a known set of values
validate_env() {
local env="$1"
case "$env" in
production|staging|development) return 0 ;;
*) echo "ERROR: invalid environment: $env" >&2; return 1 ;;
esac
}
Handling Secrets
Secrets — passwords, API keys, tokens — need special care in Bash scripts. The most dangerous place to put a secret is in a command-line argument, because those are visible to every user on the system via ps aux. The second most dangerous place is in the script source code or a version-controlled file.
# BAD — visible in 'ps aux' output to all users on the system
mysql -u root -psecretpassword mydb
# GOOD — use an environment variable (not visible in ps)
export MYSQL_PWD="$DB_PASSWORD"
mysql -u root mydb
unset MYSQL_PWD # clear it immediately after use
# GOOD — use a config file with restricted permissions
chmod 600 ~/.my.cnf # [client] section with password=xxx
mysql --defaults-file=~/.my.cnf mydb
# GOOD — read from a file (Docker secret mount, Kubernetes secret volume)
DB_PASSWORD=$(< /run/secrets/db_password)
# GOOD — fetch from a secret manager at runtime
DB_PASSWORD=$(vault kv get -field=password secret/myapp/db)
Never echo secrets, even in debug mode:
# BAD — secret appears in logs and terminal output
echo "Connecting with password: $DB_PASSWORD"
# GOOD — acknowledge the value without revealing it
echo "Connecting to $DB_HOST as $DB_USER [password redacted]"
# BAD — set -x traces every variable value, including secrets
set -x # do not use around secret-handling code
# GOOD — temporarily disable tracing around sensitive operations
set +x
sensitive_operation "$SECRET"
set -x
Loading secrets from a .env file safely:
load_env() {
local env_file="${1:-.env}"
if [[ ! -f "$env_file" ]]; then
echo "ERROR: .env file not found: $env_file" >&2
return 1
fi
# Restrict permissions so other users cannot read it
chmod 600 "$env_file"
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
# Only accept lines that look like UPPERCASE_KEY=value
if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Strip surrounding quotes if present
value="${value%\"}"
value="${value#\"}"
export "$key=$value"
fi
done < "$env_file"
}
load_env .env.production
File Permissions
Unix permission bits control who can read, write, and execute each file. Getting permissions right is especially important for scripts that handle sensitive data or are run with elevated privileges.
# Set permissions explicitly using octal notation
chmod 700 private-script.sh # owner: rwx, group: ---, other: ---
chmod 755 public-script.sh # owner: rwx, group: r-x, other: r-x
chmod 600 secrets.env # owner: rw-, group: ---, other: ---
chmod 644 config.yml # owner: rw-, group: r--, other: r--
# Recursive permission change
chmod -R 750 /var/www/myapp/
# Symbolic notation
chmod u+x script.sh # add execute for owner
chmod go-w sensitive.conf # remove write for group and others
chmod a+r public.html # add read for all users
# setgid on a directory — new files inherit the directory's group
chmod g+s /var/shared/
Set a restrictive umask in scripts that create sensitive files:
#!/usr/bin/env bash
# Set umask at the top so all files created by this script are private
umask 077 # files: 600 (rw-------), directories: 700 (rwx------)
TMP=$(mktemp) # created as 600 — only the owner can read or write it
config_file="/etc/myapp/secret.conf"
echo "password=$DB_PASSWORD" > "$config_file"
# config_file is 600 — no other user can read the password
Preventing Symlink Attacks
A symlink attack exploits the window between checking whether a file exists and creating/opening it. An attacker creates a symlink at the target path during that window, redirecting your writes to an arbitrary file. The defenses are mktemp (which uses O_EXCL) and noclobber.
# BAD — check then create (TOCTOU window between lines 1 and 2)
if [[ ! -e "/tmp/myfile" ]]; then
echo "data" > "/tmp/myfile" # attacker could create a symlink here
fi
# GOOD — mktemp creates the file atomically with O_EXCL
tmpfile=$(mktemp /tmp/myfile.XXXXXX)
echo "data" > "$tmpfile"
# GOOD — noclobber prevents overwriting existing files or symlinks
set -o noclobber
{ echo "data" > "/tmp/myfile"; } 2>/dev/null || echo "File exists or symlink detected"
set +o noclobber
Checking Script Integrity
Before running a script downloaded from the internet, verify its checksum. Never pipe curl directly to bash in production — you have no way to verify what you are executing.
# Download a script and verify its checksum before running
curl -sLO https://example.com/setup.sh
curl -sLO https://example.com/setup.sh.sha256
sha256sum -c setup.sh.sha256 || { echo "Checksum mismatch!" >&2; exit 1; }
chmod +x setup.sh
./setup.sh
# Never do this in production — no verification, no audit trail:
# curl https://example.com/setup.sh | bash
ShellCheck Integration
ShellCheck is a static analysis tool that catches security issues in Bash scripts automatically. Running it in CI means security problems are caught before code reaches production.
# Install
apt install shellcheck # or: brew install shellcheck
# Run on a script
shellcheck script.sh
# In GitHub Actions
- name: ShellCheck
uses: ludeeus/action-shellcheck@master
with:
severity: warning
ShellCheck catches: unquoted variables, missing set -e, command injection patterns, eval misuse, and dozens of other common issues.
What’s Next
The next tutorial covers common script patterns — argument parsing with getopts, usage functions, config file loading, and script locking.