Intro
Bash powers build steps, deploys, data jobs, and day-two operations. That reach also makes shell scripts a common source of outages, data leaks, and privilege misuse. This guide focuses on practical, copyable techniques for Linux administrators, SREs, and developers who maintain operational Bash. It also sets clear limits: Bash is not a security boundary. If you need complex parsing, high-risk cryptography, or untrusted multi-tenant execution, prefer a safer language (Python, Go, Rust) or a purpose-built tool.
Threat model in one page
Assume your script may face the following risks:
- Untrusted input and command injection
- Word splitting and glob expansion surprises
- Hostile environment variables and PATH manipulation
- Race conditions and unsafe temporary files
- Secret disclosure via argv, logs, or history
- Excessive privileges or setuid misuse
- Dependency compromise and unverified downloads
- Locale differences that change parsing behavior
Map risks to concrete controls and observable evidence you can check:
| Threat | Primary control | Evidence you can collect |
|---|---|---|
| Command injection from untrusted input | Quote all expansions, use arrays for argv, allowlists for actions, never use eval | Unit tests with hostile inputs do not execute unexpected commands |
| Word splitting and glob expansion | Quote variables, use arrays, treat user data as data, not shell syntax | Tests with spaces, newlines, and wildcard characters behave predictably |
| Hostile environment and PATH manipulation | Set a safe PATH, use absolute paths where appropriate, unset risky vars | Script prints effective PATH on debug, binaries resolved via command -v or absolute paths |
| Unsafe temporary files and races | Use mktemp, umask 077, verify ownership, restrict directories, avoid TOCTOU assumptions | ls -ld of temp dirs shows mode 700 and owned by the user; symlink tests fail safe |
| Secret disclosure | Do not pass secrets in argv, avoid set -x, redact logs, prefer protected files or FDs | ps output shows no secret strings; logs show REDACTED where appropriate |
| Excessive privilege | Run as unprivileged account, narrow sudo rules, separate privileged steps | id shows non-root uid; sudoers entry is minimal and audited |
| Unverified downloads and dependency compromise | HTTPS verification, pinned sources, checksum or signature verification, bounded timeouts | Checksum validation required before execution; negative test blocks on mismatch |
| Locale-sensitive parsing | Set LC_ALL=C for deterministic parsing of tools like sort, awk, grep | Tests pass under different LANG settings |
Safer expansions and argument handling
Treat user-controlled data strictly as data. Common rules:
- Always quote variable expansions: "$var" not $var.
- Use arrays to build argument vectors and preserve boundaries: cmd "${args[@]}".
- Use -- to end option parsing when passing user input to commands that accept it.
- Validate expected formats early (e.g., paths, hostnames, numeric ranges) and reject everything else.
- Do not build commands as strings.
Examples:
# Correctly preserve boundaries using arrays
args=(--name "$NAME" --path "$P")
mytool "${args[@]}" --
# Safe filename iteration
while IFS= read -r -d '' file; do
printf '%s\n' "$file"
done < <(find "$SOURCE" -type f -print0)
Dangerous constructs to avoid (and what to do instead)
- eval and bash -c with interpolated input: Both interpret text as shell code, enabling injection. Instead, assemble argv with arrays and execute directly.
- Indirect expansion like ${!name}: Easy to misuse and can cross variable boundaries. Prefer explicit maps or case statements.
- Downloading remote scripts and executing them immediately (curl | bash): Fetch to disk, verify authenticity and integrity with checksums or signatures obtained through an independent channel, inspect, then execute.
Safer alternatives appear in the comparison table below.
Strict mode with real-world caveats
set -euo pipefail is a strong default, but it is not a complete solution.
- -e (errexit) stops the script when a command fails, but not in every context. It is ignored in conditional lists like cmd || fallback and cmd && next unless the failing command is the last one. In pipelines, only the last command is considered unless you enable pipefail.
- Failures in command substitutions can be masked depending on context. When capturing output, check the exit status explicitly if correctness matters.
- -u (nounset) treats unset variables as errors. Use ${var:-default} for intentional defaults.
- pipefail makes a pipeline fail if any command fails.
- Use set -E to ensure ERR traps fire in functions and subshells.
Example patterns:
set -Eeuo pipefail
trap 'printf "ERROR at line %s\n" "$LINENO" >&2' ERR
# If you intend to ignore a failure, do it explicitly and document it
may_fail || printf 'non-fatal: may_fail failed\n' >&2
# Always check critical substitutions
data=$(curl --fail --silent --show-error --max-time 10 "$url") || {
printf 'download failed\n' >&2; exit 1; }
Do not claim strict mode makes a script secure. It simply makes failures more visible.
Input validation and safe filenames
- Use allowlists and explicit case statements for actions.
- Validate file paths with -e or -d and reject empty or root-like paths before destructive operations.
- Treat user data as opaque bytes; do not rely on word splitting. Use read -r and find -print0 for arbitrary filenames.
- End options with -- before positional data when supported.
action=${1:-}
case "$action" in
backup|verify) ;;
*) printf 'Invalid action: %s\n' "$action" >&2; exit 2 ;;
esac
Predictable execution: PATH, environment, locale
- Set a minimal, known-good PATH and export it.
- Use absolute paths for critical tools or resolve them once with command -v.
- Unset risky variables you do not need: IFS, CDPATH, GLOBIGNORE.
- Set LC_ALL=C for deterministic text processing.
PATH="/usr/sbin:/usr/bin:/sbin:/bin"; export PATH
unset CDPATH GLOBIGNORE
export LC_ALL=C LANG=C
for bin in tar sha256sum find; do command -v "$bin" >/dev/null || exit 127; done
Temporary resources and race safety
- Create unique files and directories with mktemp and a restrictive umask (077).
- Verify ownership and permissions if operating in shared directories.
- Use traps to clean up on exit, and avoid TOCTOU assumptions about files you did not just create.
umask 077
tmpdir=$(mktemp -d)
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT
Handling secrets safely
- Never pass secrets in argv or log them. Many systems expose argv via process listings.
- Prefer reading from stdin, protected files (chmod 600), or a secret manager.
- Do not enable set -x around secrets; if you must trace, redact before printing.
read -r -s -p 'Enter API token: ' API_TOKEN; printf '\n' >&2
hdr=$(mktemp "$tmpdir/h.XXXXXX"); chmod 600 "$hdr"
printf 'Authorization: Bearer %s\n' "$API_TOKEN" > "$hdr"
# Use @file to keep secrets out of argv
curl --fail --silent --show-error --header @"$hdr" "$endpoint" > "$tmpdir/resp.json"
shred -u "$hdr" 2>/dev/null || rm -f -- "$hdr"
Privileges
- Run scripts as an unprivileged service account by default.
- Avoid setuid shell scripts; they are fundamentally unsafe.
- Use narrowly scoped sudo rules, and separate privileged operations into a small, reviewed helper.
- Validate file ownership and permissions before operating on them.
Downloads and network calls
- Require HTTPS with modern TLS, pin sources, and verify integrity with checksums or signatures from an independent channel.
- Use bounded timeouts and retries, and fail closed on verification errors.
curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error \
--output "$tmpdir/tool.sh" "$url"
actual=$(sha256sum "$tmpdir/tool.sh" | awk '{print $1}')
[ "$actual" = "$expected" ] || { printf 'checksum mismatch\n' >&2; exit 1; }
Unsafe versus safer Bash patterns
| Unsafe pattern | Safer pattern | Caveat |
|---|---|---|
| Building a command string with unquoted variables | Build argv with arrays then exec: cmd "${args[@]}" | Arrays preserve argument boundaries |
| Unquoted variable in a command | Quote: rm -- "$target" | Prevents splitting and globbing |
| eval on constructed strings | Do not use eval; call the command directly with arrays | Eliminates interpretation of data as code |
| bash -c with interpolated input | Execute the target binary directly with validated args | Avoids subshell parsing of user data |
| Indirect expansion ${!name} | Use a case map or an associative array | Keeps control over which variables are read |
| for x in $(cat file) | while IFS= read -r line; do ...; done < file | Preserves spaces and special characters |
| Temporary file named in /tmp by hand | tmp=$(mktemp); chmod 600 "$tmp" | Avoids collisions and race conditions |
| Download piped directly to a shell | Download to disk, verify checksum or signature, then run | Requires an independent trust channel |
Static and dynamic validation
- Syntax checks: bash -n yourscript.sh
- Linting: ShellCheck to catch quoting, word-splitting, and array issues.
- Unit and integration tests: include negative tests with hostile inputs and weird filenames.
- CI gates: run syntax, lint, and tests on every change; require code review.
- Logging: keep it observable but non-sensitive; redact secrets and avoid tracing around credentials.
Practical example: safe, testable backup
Illustrative scenario: create a tar archive of a source directory into a destination directory, with checksum output. All work is done inside an isolated temporary workspace.
#!/usr/bin/env bash
set -Eeuo pipefail
set -o noclobber
umask 077
export LC_ALL=C LANG=C
PATH="/usr/sbin:/usr/bin:/sbin:/bin"; export PATH
trap 'printf "ERROR at line %s\n" "$LINENO" >&2' ERR
usage() {
printf 'Usage: %s --action backup --source DIR --dest DIR\n' "$0"
}
# Parse flags
ACTION=""; SOURCE=""; DEST=""
while [ $# -gt 0 ]; do
case "$1" in
--action) ACTION=${2:-}; shift 2 ;;
--source) SOURCE=${2:-}; shift 2 ;;
--dest) DEST=${2:-}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
case "$ACTION" in
backup) ;;
*) usage; exit 2 ;;
esac
# Validate paths
[ -d "$SOURCE" ] || { printf 'Source must be a directory\n' >&2; exit 2; }
[ -n "$DEST" ] || { printf 'Dest must be provided\n' >&2; exit 2; }
[ -d "$DEST" ] || { printf 'Dest must be an existing directory\n' >&2; exit 2; }
# Resolve required tools
for bin in tar sha256sum find; do command -v "$bin" >/dev/null || { printf 'Missing %s\n' "$bin" >&2; exit 127; }; done
# Isolated workspace
workdir=$(mktemp -d)
cleanup() { rm -rf -- "$workdir"; }
trap cleanup EXIT
# Create archive within workspace, then move into place
archive_name="backup-$(date +%Y%m%d-%H%M%S).tar"
archive_path="$workdir/$archive_name"
(
cd "$SOURCE"
# Use tar with a deterministic path
tar -cf "$archive_path" .
)
# Compute checksum
sum=$(sha256sum "$archive_path" | awk '{print $1}')
# Move results atomically into destination
final_archive="$DEST/$archive_name"
final_checksum="$DEST/$archive_name.sha256"
# Ensure we are not clobbering an existing file unintentionally
[ -e "$final_archive" ] && { printf 'Refusing to overwrite existing archive\n' >&2; exit 1; }
[ -e "$final_checksum" ] && { printf 'Refusing to overwrite existing checksum\n' >&2; exit 1; }
mv -- "$archive_path" "$final_archive"
printf '%s %s\n' "$sum" "$archive_name" > "$final_checksum"
chmod 600 "$final_checksum"
printf 'Created %s and %s\n' "$final_archive" "$final_checksum"
Validation and quick tests you can run safely:
# Static checks
bash -n safe_backup.sh
shellcheck -s bash safe_backup.sh
# Prepare an isolated test dataset with tricky filenames
src=$(mktemp -d); dest=$(mktemp -d)
mkdir -p "$src/sub dir"; touch "$src/sub dir/file [1].txt" "$src/asterisk*.txt"
# Run the backup
./safe_backup.sh --action backup --source "$src" --dest "$dest"
# Verify the archive exists and the checksum lines up
sha256sum -c "$dest"/*.sha256 --ignore-missing --strict
# Negative test: attempt to overwrite
./safe_backup.sh --action backup --source "$src" --dest "$dest" || true
Pre-deployment hardening and test checklist
| Item | Priority | Owner | Evidence |
|---|---|---|---|
| Shebang and strict options set -Eeuo pipefail with ERR trap | High | Header present and trap prints line on failure | |
| Sanitize PATH and environment, set LC_ALL=C | High | PATH logged on debug, locale exported | |
| Quote expansions and use arrays for argv | High | ShellCheck clean; tests with spaces and wildcards pass | |
| No eval, backticks, or bash -c with interpolated input | High | Code review checklist item; grep checks in CI | |
| Input allowlists and explicit validation | High | case statement for actions; invalid inputs rejected | |
| Temporary dirs via mktemp and trap cleanup | Medium | mktemp usage in code; temp dirs removed on exit | |
| Privilege model: unprivileged by default, narrow sudo | High | id output non-root; sudoers entry reviewed | |
| Secrets: no argv leakage, redacted logs, no set -x near secrets | High | ps shows no secrets; logs contain REDACTED | |
| Network: HTTPS only, pinned sources, checksum or signature, timeouts | High | Checksum verification required before execution | |
| Static analysis: bash -n and ShellCheck clean | Medium | CI artifacts show success | |
| Tests: unit, integration, and negative inputs | High | Test suite passes in CI | |
| Code review performed before release | High | PR link with approvals |
When to replace Bash
Choose Python, Go, Rust, or a declarative automation tool when:
- You must run untrusted or multi-tenant input safely.
- The task needs complex parsing, structured data handling, or robust concurrency.
- Long-term maintainability and testability outweigh quick one-liners.
- You need strong cryptography, verified parsing, or a rich library ecosystem.
Use Bash for small, glue-like orchestration where arguments, environments, and side effects are tightly controlled.
Conclusion
Hardened Bash is about disciplined defaults and explicit intent: quote expansions, validate inputs with allowlists, avoid eval and bash -c, manage PATH and environment predictably, use mktemp and traps for temporary resources, keep secrets out of argv and logs, minimize privileges, and verify downloads before use. Pair these practices with static checks and negative tests. When the problem outgrows Bash, switch to a safer language or tool before risk does it for you.