Skip to main content
Bash intermediate Lesson 19 of 22

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

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.

Frequently Asked Questions

How do I pass a secret to a command without exposing it in ps output?
Use environment variables or a file descriptor instead of command-line arguments. Command-line arguments are visible to all users via ps aux. Write the secret to stdin or a file with restricted permissions.
Why is eval dangerous?
eval executes its argument as a shell command. If user input reaches eval, an attacker can inject arbitrary commands. Avoid eval entirely — there is almost always a safer alternative.
What is a TOCTOU race condition?
Time-Of-Check-To-Time-Of-Use. You check if a file exists, then open it — but between the check and the open, an attacker replaces the file with a symlink. Use mktemp and O_EXCL file creation to avoid this.