How to Validate User Input in a Shell Script the Safe Way
Validate input in a shell script by checking arguments, using allow lists, rejecting leading dashes, and quoting values before they reach commands.

Input to a script arrives as strings, and the shell will happily pass them to rm, ssh or curl. Validation is what stands between a typo and a wiped directory, and between a user and an injected option. The patterns below use Bash 5.2 and the same thinking as preventing command injection.
Check the argument count first
if (( $# != 2 )); then
echo "usage: ${0##*/} NAME PORT" >&2
exit 2
fi
name=$1 port=$2Under set -u, reading $2 when it is missing aborts with an obscure message; the count check gives a clear one. See checking the number of arguments.
Match against an allow list
case $name in
''|-*|*[!A-Za-z0-9._-]*)
echo "invalid name: $name" >&2
exit 2 ;;
esac
if ! [[ $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "invalid port: $port" >&2
exit 2
fiThe case pattern rejects an empty value, one starting with a dash, and any value containing a character outside letters, digits, dot, underscore and hyphen. The port check combines a regex for digits with an arithmetic range test, and the regex check must come first because arithmetic on non-numeric text would evaluate it as a variable name. A longer treatment of number checks is in checking if a value is a number.
Stop values becoming options
rm -- "$file"
grep -F -- "$pattern" "$logfile"A filename that starts with -, such as -rf, would be read as an option. -- ends option parsing in POSIX utilities, so what follows is always an operand. Use it every time a value you did not write goes into a command line, and pair it with a prefix like ./ for programs that do not support it. The convention is defined in the POSIX Utility Conventions.
Validate paths against a base
base=/srv/uploads
target=$(realpath -e -- "$base/$1") || exit 1
[[ $target == "$base"/* ]] || { echo "outside $base" >&2; exit 1; }realpath -e resolves symlinks and .. and fails if the path does not exist, so the prefix check cannot be fooled by ../../etc. Both checks are needed, and realpath and readlink explains the flags.
Validate early, validate at the edge
Every script has a boundary where data arrives from outside: positional parameters, environment variables, a file or a network response. Validate there, once, and pass clean values inward. A function deep inside the script should not need to wonder whether its argument might be empty or contain a newline. Doing the checks in the first twenty lines also gives a clear failure with a usage message before any file has been touched, instead of a half-finished run.
The most reliable style is an allow list. Say what a valid value looks like, accept it, and reject everything else. A deny list, such as "no semicolons", always forgets a case: backticks, a dollar sign, a newline, a leading dash, or a character from another encoding that a later tool interprets differently. A pattern like [A-Za-z0-9._-] is short, easy to read in review, and keeps working when a new tool is added downstream.
Numbers, booleans and choices
Numeric options are the commonest case. A regular expression checks the shape, and an arithmetic test checks the range, and the order matters because arithmetic on text evaluates it as an expression. A value of 1+1 or a[$(cmd)] inside (( )) can execute code in Bash, since array subscripts are evaluated. Match the digits first, then compare. Leading zeros deserve a note too: 08 inside (( )) is read as invalid octal, so strip zeros with 10#$value when the input may carry them.
For a choice among fixed words, a case statement is clearer than a chain of comparisons and gives one place to list the valid values and the usage text. For a yes or no answer, accept a few spellings in a case and treat anything else as an error, instead of treating "anything not yes" as no. A typo like "ys" should stop the run when the action is a deletion.
case ${ENVIRONMENT:-} in
dev|staging|prod) ;;
*) echo "ENVIRONMENT must be dev, staging or prod" >&2; exit 2 ;;
esac
confirm() {
local reply
read -r -p "$1 [y/N] " reply
case $reply in
y|Y|yes|YES) return 0 ;;
n|N|no|NO|'') return 1 ;;
*) echo "please answer y or n" >&2; return 1 ;;
esac
}Files, environment and standard input
Input is not only arguments. A path from the environment, a line read from a file and a response from curl are all untrusted, and the same rules apply. Check that a file exists and is readable before opening it, check its size if a huge file would be a problem, and parse it with a tool built for the format. A shell loop that splits JSON with cut works until a value contains a comma. Use jq for JSON, a CSV-aware tool for CSV, and a proper parser for anything with nesting.
Environment variables deserve the same suspicion in scripts that run with extra privilege. PATH, IFS, LD_PRELOAD and BASH_ENV change how commands run, and a script started through sudo should set its own PATH at the top and call commands by full path where the risk is real. Reading a secret from the environment is fine, but check that it is set and non-empty with ${VAR:?message}, which aborts with the message when the variable is missing.
Report errors usefully
A rejected value should produce a message that names the field, shows what was received and says what is accepted. Print to stderr, return status 2 for a bad invocation, and keep the text free of the raw value when it might contain control characters; printf '%q' prints a safe, quoted form. A test for the validator is cheap: feed it an empty string, a space, a leading dash, a very long string, a glob character and a newline, and check that each is rejected for the right reason.
A short test harness
Validation code is easy to get wrong in the direction that matters, accepting a bad value, so give it a test. A loop over a list of known bad inputs, each of which must be rejected, and a second list of good inputs that must pass, runs in a fraction of a second. Keep the lists next to the script and add to them whenever a bug report arrives, so the same mistake cannot return. Include the awkward members every time: the empty string, a single space, a value with a newline in the middle, a value that begins with a dash, one with a glob character, and one so long that a downstream tool might truncate it. A validator that survives those is good enough for most scripts, and the rest of the risk belongs to the design, which should avoid handing raw input to a shell at all.
Where this does not apply
Validation reduces risk and does not remove the need to quote: a name that passes the pattern above is still quoted in every expansion. Never pass input to eval or to bash -c after validating it; a pattern you believe is tight is one clever character from failing, so avoid constructing code from input at all, as covered in avoiding eval. Hostnames, email addresses and URLs have grammars too complex for a short pattern, and a script that must accept them is better off delegating to a tool that parses them. The Bash pattern matching reference describes the glob forms used in the case statement.


