Shell Scripting Security Best Practices Intermediate May 2026

Shell Scripting Script Security

Defend against injection attacks, privilege escalation, insecure temp files, secrets leakage, and path manipulation. Write scripts that are safe to run in automated pipelines and with elevated privileges.

Scripts running in production have significant power — they touch databases, execute system commands, and often run with root or service account privileges. A single security mistake can expose credentials, allow arbitrary code execution, or let attackers escalate privileges. These patterns prevent the most common bash security issues.

BASH
# ── The fundamental rule: always double-quote variables ────
filename="my file.txt"

# UNSAFE — word splitting: treats spaces as argument separators
rm $filename              # runs: rm my file.txt — removes "my" AND "file.txt"!

# SAFE
rm "${filename}"          # removes "my file.txt" (one file)

# ── Glob injection ────────────────────────────────────────
user_input="*.sh"

# UNSAFE — expands glob, could match unintended files
echo $user_input           # expands to: script1.sh script2.sh ...

# SAFE
echo "${user_input}"       # prints: *.sh (literal)

# ── Command injection ─────────────────────────────────────
user_host="prod-01; rm -rf /"

# UNSAFE — allows command injection via semicolon
ping $user_host            # pings prod-01 AND then runs rm -rf /!

# SAFE — always quote, always validate
if [[ "${user_host}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
  ping -c 1 "${user_host}"
else
  echo "Invalid hostname" >&2; exit 1
fi
BASH
# ── Validate all external input ───────────────────────────
validate_hostname() {
  local h="${1}"
  [[ "${h}" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]] \
    || { echo "Invalid hostname: ${h}" >&2; return 1; }
}

validate_port() {
  local p="${1}"
  [[ "${p}" =~ ^[0-9]+$ ]] && (( p >= 1 && p <= 65535 )) \
    || { echo "Invalid port: ${p}" >&2; return 1; }
}

validate_env() {
  case "${1}" in
    development|staging|production) return 0 ;;
    *) echo "Invalid env: ${1}" >&2; return 1 ;;
  esac
}

validate_path() {
  local p="${1}"
  # Reject path traversal
  [[ "${p}" == *..* ]] && { echo "Path traversal denied: ${p}" >&2; return 1; }
  [[ "${p}" =~ ^[a-zA-Z0-9/_.-]+$ ]] \
    || { echo "Invalid path: ${p}" >&2; return 1; }
  realpath -e "${p}" >/dev/null 2>&1 \
    || { echo "Path does not exist: ${p}" >&2; return 1; }
}

# Usage
HOST="${1}"
PORT="${2}"
validate_hostname "${HOST}" || exit 1
validate_port     "${PORT}" || exit 1
BASH
# ── NEVER hardcode secrets ────────────────────────────────
# WRONG:
DB_PASS="s3cr3tpassword"   # visible in git history, ps aux, logs

# RIGHT: read from environment or file
DB_PASS="${DB_PASS:?DB_PASS must be set}"

# ── Prevent secrets appearing in ps aux ───────────────────
# WRONG — password visible in process list:
mysql -u root -p"${DB_PASS}" mydb

# RIGHT — use config file or stdin:
mysql --defaults-extra-file=<(printf '[client]\npassword=%s\n' "${DB_PASS}") mydb
# Or set MYSQL_PWD env var (visible in env but not ps args):
MYSQL_PWD="${DB_PASS}" mysql -u root mydb

# ── Don't log secrets ─────────────────────────────────────
# WRONG:
echo "Connecting with pass: ${DB_PASS}"   # goes to log!
# RIGHT:
echo "Connecting to ${DB_HOST} as ${DB_USER}"

# ── Secure temp files for secrets ─────────────────────────
SECRET_FILE=$(mktemp)
chmod 600 "${SECRET_FILE}"
trap 'rm -f "${SECRET_FILE}"' EXIT

printf '[client]\npassword=%s\n' "${DB_PASS}" > "${SECRET_FILE}"
mysql --defaults-extra-file="${SECRET_FILE}" mydb
BASH
# ── Safe temp files ───────────────────────────────────────
# WRONG — predictable name, race condition (TOCTOU):
tmp=/tmp/script_temp.$$

# RIGHT — unpredictable, atomic creation:
tmp=$(mktemp)
chmod 600 "${tmp}"
trap 'rm -f "${tmp}"' EXIT

# ── Harden PATH ───────────────────────────────────────────
# Always set explicit PATH at the top of privileged scripts
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

# ── Check who is running the script ──────────────────────
require_root() {
  [[ $(id -u) -eq 0 ]] || { echo "ERROR: Must run as root" >&2; exit 1; }
}

refuse_root() {
  [[ $(id -u) -eq 0 ]] && { echo "ERROR: Do not run as root" >&2; exit 1; }
}

# ── Run commands with minimal privilege ──────────────────
# Drop to a specific user for sensitive operations
su -s /bin/bash -c "./backup.sh" vriddh
runuser -l vriddh -c "./report.sh"

# ── umask — control default file permissions ─────────────
umask 077   # new files: 600 (owner only), dirs: 700
# Place at top of script to prevent world-readable temp files
bash — shellcheck security warnings
vriddh@prod-01:~/scripts$shellcheck deploy.sh
deploy.sh:14:8: warning: Double quote to prevent globbing and word splitting. [SC2086]
deploy.sh:22:5: error: Prefer ${var:-default} to handle empty/unset vars. [SC2236]
deploy.sh:31:12: warning: Use 'find ... -print0 | xargs -0' for filenames with spaces. [SC2038]
vriddh@prod-01:~/scripts$shellcheck --severity=error deploy.sh && echo "No errors"
No errors
✔ Security checklist — Always double-quote "${variables}". Always validate user input with regex before use. Never hardcode secrets — read from env or file. Set umask 077 in privileged scripts. Always hardcode PATH in scripts that run as root. Use mktemp not predictable filenames. Run shellcheck before every deploy — it catches most injection risks automatically.