Join our Newsletter — 33% off our NHI Course
Home› Guides› Secure Bash and Shell Scripting Guide: Strict Mode,…
Guide Endpoint Security

Secure Bash and Shell Scripting Guide: Strict Mode, Quoting, Secrets and Safe Execution

← All guides
By NHI Mgmt Group, edited by Lalit Choda Updated 28 October 2026 15 min read
On this page

Shell scripts run deployments, backups, CI jobs and cron tasks with real credentials and root privileges, yet most are written without any of the safety habits applied to application code. A single unquoted variable can turn a cleanup job into rm -rf /, an unchecked failure can push a half-built release, and a script that echoes its environment can publish every secret in a pipeline. This guide covers what set -euo pipefail really does and where it fails, quoting and input handling, safe temporary files, handling secrets, privilege and execution controls, and how to lint, test and monitor scripts at scale. It is for platform engineers, SREs, DevOps teams and security reviewers who own or audit Bash and POSIX shell scripts. Windows scripting is covered separately in the PowerShell Security Guide.

Key takeaways

  • Strict mode is a seatbelt, not error handling. set -euo pipefail catches many silent failures, but the Bash manual lists contexts where -e is ignored, so critical steps still need explicit checks.
  • Quote every expansion. Unquoted $var is split on whitespace and expanded as a glob; ShellCheck SC2086 is the single most valuable warning to enforce.
  • Never build commands from strings. Avoid eval, pass untrusted values as separate arguments, validate them against an allowlist and end options with --.
  • Keep secrets out of arguments, traces and logs. Command-line arguments are visible to other users through ps, and set -x prints expanded values to standard error.
  • Treat scripts as code with an owner. Lint in CI, review changes, pin and verify anything downloaded, and remove write access for anyone but the owner on scripts that run as root or from cron.

What set -euo pipefail does, and where it fails

The so-called unofficial strict mode combines three shell options. Each one changes a default that otherwise hides failures.

OptionWhat the Bash manual says it doesWhat it preventsKnown gaps
set -e (errexit)Exit immediately if a pipeline, list or compound command returns a non-zero statusContinuing after a failed cd, download or build stepIgnored in if, while and until tests, in any command of an && or || list except the last, in any pipeline command but the last (unless pipefail), and after !
set -u (nounset)Treat unset variables and parameters (other than @ and *) as an error during expansionTypos and missing inputs expanding to empty strings, for example rm -rf "$DIR/"* with DIR unsetDoes not catch variables that are set but empty
set -o pipefailA pipeline returns the status of the last command to exit non-zero, or zero if all succeedcurl ... | tar x succeeding when the download failedCan fail intermittently when a reader exits early, for example cmd | head -n1
set -x (xtrace)Print each command and its expanded arguments to standard error before running itDebugging onlyPrints secrets in clear text into logs

The Bash community FAQ (BashFAQ/105) documents why set -e is unreliable: a function called inside an if or || runs with errexit suspended for its whole body; local var=$(cmd) returns the status of local, not of cmd; command substitutions ignore errexit unless shopt -s inherit_errexit is set (Bash 4.4 and later); and ((i++)) returns non-zero when the result is zero, which can abort a script unexpectedly. ShellCheck SC2155 flags the related export foo="$(mycmd)" pattern, which masks the return value.

  • Recommended header: #!/usr/bin/env bash, then set -Eeuo pipefail, shopt -s inherit_errexit where Bash 4.4+ is guaranteed, and an ERR trap that logs the failing line and exits. -E makes the ERR trap apply inside functions.
  • Explicit checks for critical steps: write cd -- "$target" || exit 1 and test the result of deployments and database changes directly. Do not rely on errexit for anything that would be destructive if skipped.
  • Required inputs: use "${VAR:?VAR must be set}", which fails if the variable is unset or empty. ShellCheck SC2115 recommends exactly this for paths passed to rm -rf.
  • Cleanup: register trap cleanup EXIT so temporary files and locks are removed on success, failure and interruption.

Quoting, word splitting and input handling

ShellCheck's explanation of SC2086 is the clearest summary of the problem: echo $1 looks like "print the first argument" but actually splits it on IFS characters, expands each piece as a glob and joins the results. Any value containing spaces, newlines or * changes the command. When the value comes from a file name, a Git branch name, a webhook payload or a CI variable that an outsider can influence, unquoted expansion becomes an injection path.

Unsafe patternRiskSafer formShellCheck
rm -rf $DIR/*Splitting, globbing, or deleting from / if emptyrm -rf -- "${DIR:?}"/*SC2086, SC2115
for f in $(ls *.log)Breaks on spaces, globs inside namesfor f in ./*.log; do ...; doneSC2045, SC2046
eval "cmd $input"Arbitrary command executionArrays: args=(--flag "$input"); cmd "${args[@]}"Style guide: avoid eval
bash -c "tool $USER_VALUE"Value parsed as shell codebash -c 'tool "$1"' _ "$USER_VALUE" or call the tool directlyReview manually
grep $pattern fileValue starting with - treated as an optiongrep -e "$pattern" -- fileReview manually
export x="$(cmd)"Failure of cmd maskedx="$(cmd)"; export xSC2155
  • Validate against an allowlist: OWASP's OS Command Injection Defense Cheat Sheet recommends validating both the command (against a list of allowed commands) and each argument, with positive allowlists or tight regular expressions such as ^[a-z0-9]{3,10}$ that exclude metacharacters and whitespace. In Bash, [[ $value =~ ^[a-z0-9-]{1,40}$ ]] before use is the equivalent.
  • Argument injection is still injection: the same cheat sheet notes that every OS command injection is also an argument injection. Quoting stops new commands but not a value like --output=/etc/cron.d/x. Use -- to end option parsing where the tool supports it.
  • Untrusted CI inputs: pull request titles, branch names and issue bodies interpolated into run: steps are a known route to pipeline takeover. Pass them through environment variables and quote them, never through template expansion into the script text. See the GitHub and GitHub Actions Security Guide.
  • Reading data: use while IFS= read -r line to keep backslashes and leading spaces intact, and find ... -print0 | xargs -0 or find -exec ... {} + for file names.

See the Command Injection Guide for the application side of the same weakness.

Temporary files, paths and the environment

  • Use mktemp: the mktemp manual states that files are created u+rw and directories u+rwx, minus umask restrictions, which prevents other users reading or pre-creating them. Never build names like /tmp/myscript.$$, which are predictable, and never use mktemp -u, which the manual labels unsafe because the name can be claimed before use. A typical pattern is tmp="$(mktemp -d)" followed by trap 'rm -rf -- "$tmp"' EXIT.
  • Set a restrictive umask: umask 077 at the top of scripts that write credentials, keys or exports.
  • Do not trust PATH in privileged scripts: set PATH explicitly (for example /usr/sbin:/usr/bin:/sbin:/bin) in scripts run by root, cron or sudo, or call binaries by absolute path, so a writable directory early in PATH cannot shadow tar or cp.
  • Privileged mode: the Bash manual describes -p as a mode in which $BASH_ENV and $ENV files are not processed, shell functions are not inherited from the environment and SHELLOPTS, BASHOPTS, CDPATH and GLOBIGNORE are ignored. That list is a useful reminder of which inherited variables can change script behaviour.
  • Avoid clobbering: set -C (noclobber) stops > overwriting existing files, which protects against symlink tricks on predictable output paths.
  • Patch the interpreter: Shellshock (CVE-2014-6271 and CVE-2014-7169) let crafted environment variables execute code when Bash started, and both are in the CISA Known Exploited Vulnerabilities catalogue. Old embedded and appliance shells still carry it.

Handling secrets in shell scripts

Shell scripts are where secrets most often leak, because the shell makes printing, passing and persisting values effortless.

  • Not on the command line: GitHub's documentation for Actions secrets warns that command-line arguments may be visible to other users through ps, and recommends environment variables or standard input instead. Prefer tools that read secrets from a file descriptor or standard input, for example --password-stdin style options.
  • No tracing near secrets: if set -x is needed for debugging, wrap secret-handling sections with set +x and set -x, or better, never enable tracing in production pipelines. CI masking only catches values the platform knows are secrets; GitHub documents ::add-mask:: for derived values.
  • No secrets in the script file: fetch them at run time from a secrets manager CLI or a mounted file, and keep them in local variables rather than exporting them, so child processes do not inherit them. See the Environment Variables and .env Files guide.
  • History: commands typed interactively with a secret land in ~/.bash_history. Researchers in the Indian Government Breach recovered database passwords from exposed .bash_history files.
  • Heredocs and redirects: writing a secret to a file with cat > file inherits the umask; set umask 077 first or create the file with install -m 600.

Where identity fits

Automation scripts are usually the real holders of non-human identities: the cron job with a database password, the deploy script with an SSH key, the backup script with cloud storage keys, the CI step with a registry token. Each should run as a dedicated service account with only the rights it needs, not as root or as a shared admin user, and its credential should have an owner and rotation date. Scripts invoked through sudo need tight sudoers rules naming the exact script path, which must not be writable by the invoking user. See the sudo and sudoers Security Guide and the SSH Key Management Guide.

Supply chain: downloaded and shared scripts

The Codecov breach is the reference case. Customers ran Codecov's Bash Uploader by downloading and executing it in CI. An attacker modified one line to send the output of env and the Git remote to an external server, and the change ran for about two months until a customer noticed the published checksum no longer matched.

  • Pin and verify: download scripts and installers to a file, verify a checksum or signature published through a separate channel, then execute. Avoid piping curl straight into bash in CI and production builds.
  • Vendor internally: keep third-party scripts in an internal repository or artefact store at a reviewed version, and update them deliberately.
  • Least privilege per job: scope CI secrets to the jobs that need them so a compromised helper script sees as little as possible.
  • Signed shared libraries: shell function libraries sourced by many scripts are a single point of compromise; protect their repository and branches like production code.

Choosing the right tool and scale

  • When to stop using shell: the Google Shell Style Guide says shell should only be used for small utilities or simple wrapper scripts, and that a script over 100 lines or with non-straightforward control flow should be rewritten in a more structured language. Complex parsing, JSON handling and anything that accepts external input are better in Python or Go.
  • Bash or POSIX sh: Bash gives arrays, [[ ]], pipefail and inherit_errexit, which make safe scripting easier. POSIX sh (dash, BusyBox) is more portable but lacks arrays and, in older implementations, pipefail. Choose Bash unless the target platform rules it out, and declare it in the shebang.
  • No SUID scripts: the same style guide forbids SUID and SGID on shell scripts because shell is too hard to secure for that use. Use sudo with a specific rule instead.
  • Small teams: ShellCheck in the editor and in CI, a standard header template and a review rule for scripts that run as root deliver most of the value.
  • Large estates: add an inventory of scripts that run from cron, systemd timers, CI and configuration management, ownership tags, and file integrity monitoring on privileged script directories.

How to implement: rolling out safer scripting

  1. Inventory privileged scripts. List scripts executed by root crontabs, /etc/cron.*, systemd services and timers, sudoers rules and CI pipelines. Record owner, run-as account and the credentials each uses.
  2. Fix permissions first. Make privileged scripts and their directories owned by root and not group or world writable. This is a quick change with high impact against local privilege escalation.
  3. Add ShellCheck in report mode. Run it across repositories without failing builds, review the counts, and fix SC2086, SC2046, SC2115 and SC2155 findings in privileged scripts first.
  4. Introduce a standard header. Apply the strict-mode header, ERR and EXIT traps, explicit PATH and umask to new scripts, then to existing ones in priority order. Test each in staging, because enabling -u or pipefail can expose latent bugs that were previously silent.
  5. Move secrets out. Replace inline credentials with secrets manager lookups or mounted files, rotate the old values, and remove set -x from secret-handling code.
  6. Enforce. Switch ShellCheck to blocking for new and changed files, require review for scripts that run privileged, and pin external downloads.
  7. Roll back safely. Keep the previous script version deployable; if strict mode breaks a job, revert that script, fix the underlying error and reapply rather than removing strict mode globally.

Detection and measurement

  • Process auditing: auditd execve rules or EDR telemetry on interactive and non-interactive shells, focusing on shells spawned by web servers, database engines and CI runners. ATT&CK tracks shell abuse as T1059.004 Unix Shell. See the Linux Logging and auditd for Defenders Guide.
  • Integrity of privileged scripts: file integrity monitoring on cron directories, systemd unit directories and script paths named in sudoers. Changes to shell start-up files map to T1546.004 Unix Shell Configuration Modification. See the Linux File Integrity Monitoring Guide.
  • Download and execute: alert on curl or wget output piped into a shell on servers, a common pattern in T1105 Ingress Tool Transfer.
  • Log hygiene: secret scanning on CI logs and log pipelines catches tracing and echo mistakes.
MetricWhat it shows
Privileged scripts with an owner and run-as account recordedInventory coverage
ShellCheck warnings per 1,000 lines in privileged scriptsCode quality trend
Privileged scripts writable by non-root accountsLocal privilege escalation exposure, target zero
Scripts containing inline credentialsSecrets migration progress, target zero
CI jobs executing unpinned remote scriptsSupply chain exposure

Framework mapping

FrameworkReferenceRelevance
MITRE ATT&CKT1059.004 Unix Shell; T1546.004 Unix Shell Configuration Modification; T1105 Ingress Tool Transfer; T1552.001 Credentials In FilesHow shells and scripts are abused, and what to detect
NIST SP 800-53 Rev. 5SI-10 Information Input Validation; CM-7 Least Functionality; SA-11 Developer Testing and Evaluation; SI-7 Software, Firmware, and Information Integrity; IA-5(7) No Embedded Unencrypted Static AuthenticatorsInput validation, static analysis, script integrity and no inline credentials
NIST CSF 2.0PR.PS-06; PR.PS-01Secure development practices and configuration management
MITRE CWECWE-78 OS Command Injection; CWE-377 Insecure Temporary FileThe two most common script weaknesses
CIS Controls v8.1Control 2 Inventory and Control of Software Assets; Control 16 Application Software SecurityKnowing which scripts run, and developing them securely

Common failure patterns

  • Empty variable, recursive delete. rm -rf "$PREFIX/"* with PREFIX unset deletes from the root directory. ShellCheck SC2115 cites a real case in a widely used Linux client. Use ${PREFIX:?} and set -u.
  • Strict mode as a false guarantee. A deploy function called in if deploy; then keeps running after its first failure because errexit is suspended inside it.
  • Tracing in CI. set -x left on in a pipeline writes tokens into build logs that many people and integrations can read.
  • Writable root cron scripts. A script run by root from cron but writable by a service account turns any compromise of that account into root.
  • Piping remote scripts into a shell. Unverified installers in CI give whoever controls the URL code execution with the pipeline's secrets, as the Codecov breach showed.
  • Template injection into run steps. Branch names or titles expanded directly into shell text in CI; in the Nx s1ngularity attack, injected commands through a pull request title in a GitHub Actions workflow led to theft of the npm publishing token.

Practitioner checklist

  • Start every Bash script with an explicit shebang and set -Eeuo pipefail, plus ERR and EXIT traps.
  • Add explicit error checks for destructive and deployment steps; do not rely on errexit alone.
  • Quote every expansion, use arrays for argument lists and remove eval.
  • Validate external input against an allowlist and end options with --.
  • Create temporary files with mktemp, set umask 077 and clean up in an EXIT trap.
  • Set PATH explicitly in privileged scripts, and make them writable only by root.
  • Keep secrets off the command line, out of traces and out of the script file.
  • Run ShellCheck in CI, blocking on new findings in privileged scripts.
  • Pin and verify every downloaded script; never pipe remote content into a shell in production.
  • Monitor integrity of cron, systemd and sudoers-referenced scripts.

Standards and references

Related NHI Mgmt Group resources: PowerShell Security Guide · Command Injection Guide · sudo and sudoers Security Guide · Linux Detection and Response Guide · CI/CD Security Guide

Free weekly newsletter

Subscribe to the NHI & AI Identity Journal

The latest on NHI and Agentic AI security – articles, research, breaches, news and events every week.

Bonus 33% off our NHI Course when you subscribe.

NHIMG Editorial Note
Researched and written by the NHI Mgmt Group editorial team using AI tools, under the editorial direction of Lalit Choda. Facts are drawn from the sources listed on this page and checked by our automated quality process. Last updated 28 October 2026. Spotted an error? Use our contact page.
NHI Mgmt Group, the independent authority on Non-Human Identity, IAM and Agentic AI security. nhimg.org