OpenSSH Remote Commands: Preserve Data Across the Shell Boundary
Understand how ssh assembles remote commands, why local quoting is not remote argv quoting, and how to pass data without creating a second shell parse.
An ssh command that looks like a local program invocation can cross two command interpreters. Your local shell parses the line first. The SSH client then sends a command string to the server, where the account’s configured shell normally interprets it. The remote side does not receive an operating-system argument vector that preserves every local quote boundary. OpenSSH documents that additional command arguments are appended to the command, separated by spaces, before it is sent for execution. That flattening is why quoting a variable locally does not automatically make it safe as a remote argument.
The reliable design is to keep the remote program or script fixed and transport changing data over a data channel, usually standard input or a carefully encoded protocol. Treat the remote command as code, validate the target and operation, and keep filenames and user-provided values out of command text. This article shows the boundary, the failure modes, and patterns you can test before using remote execution in deployment or administration scripts.
There are two parsers, not one
In ssh host command, the local shell expands local variables and applies local quoting. The client uses its resulting command words to construct the request. At the server, sshd runs the requested command through the account’s shell with a -c style command string, subject to server configuration and account policy. Therefore characters that the local shell already treated as ordinary data can become syntax when the remote shell parses the flattened string.
For example, this may look careful but does not preserve a remote argv vector:
path='reports/quarterly summary.txt'
ssh backup.example.net cat "$path"
The double quotes protect the local expansion from local word splitting and globbing. They are removed by the local shell; they are not sent as a structured quote annotation. The resulting words are joined for the SSH command request. A space inside path can therefore become a word boundary remotely. Worse, if the value contains shell metacharacters, the server-side parser can interpret them as operators. The exact result depends on the remote shell and the bytes in the command string, not on the local source spelling.
Always distinguish three representations: the text typed by the operator, the local shell’s argument vector, and the remote command string. A shell’s quotes describe how to build one local argument. They are not a universal serialization format for another shell. The problem is conceptually similar to constructing SQL or a regular expression by concatenating data into source text: a boundary disappears when code and data share one string.
Keep remote code constant and send values on standard input
When the task is to process a record or configuration, stream the record to a fixed remote program rather than interpolating it into the command:
printf '%s\n' "$record" |
ssh -- backup.example.net 'exec /usr/local/libexec/process-record'
Here printf formats the local data; the remote command is constant. The remote program reads standard input as data. -- terminates options for clients that support it, while the quoted remote command is still a literal local shell word. Use a remote executable with a documented input format and explicit validation. Do not assume every utility reads a complete record from stdin or that newline is a safe delimiter if records can contain embedded newlines.
For a one-off script kept locally, use SSH’s stdin channel to deliver the program and keep the command fixed:
ssh -- backup.example.net 'exec /bin/sh -s' < ./remote-maintenance.sh
This pattern sends the script bytes to the remote shell’s standard input. It is useful when the script itself is trusted and version-controlled, but it does not make arbitrary text inside that script safe. Script variables still need normal validation, and any data the script reads from stdin must not be mixed ambiguously with script source. If both code and a payload must travel, use separate channels or a framed protocol rather than hoping the receiver can guess where one ends.
The remote command should identify an executable explicitly when the operation depends on a particular tool. PATH on a non-interactive remote command can differ from an interactive login. Avoid relying on aliases, shell functions, interactive startup files, or the user’s prompt configuration. A small remote helper with a stable absolute path is generally easier to audit than a long command assembled by a CI job.
When a value must be an argument, use a structured transport
Some remote programs require a pathname or option as an argument rather than stdin. Prefer a protocol or tool that transports that value as data. For copying files, use scp or rsync with their documented path semantics and test the version pair; do not replace a transfer tool with ssh host "cat > $path" unless a fixed remote helper receives and validates the path separately. A helper can accept an identifier from stdin, map it to an allowed directory, reject separators or traversal, and open the resulting file using safe filesystem APIs.
If you control both endpoints, define an encoding with an unambiguous framing rule. JSON lines work only if each message is one JSON value per line and embedded newlines are escaped by a correct JSON serializer. NUL-delimited fields are suitable for Unix pathnames because NUL cannot occur in a pathname, but then every producer and consumer must preserve NUL bytes and handle partial reads. Avoid inventing a quoting scheme around spaces: filenames can contain spaces, tabs, quotes, wildcard characters, and newlines.
Do not use eval or sh -c on received data as a way to recover arguments. Those operations deliberately parse data as shell code. If a remote command needs one of a finite set of actions, send an action identifier and dispatch through an explicit allowlist:
case $operation in
status) exec /usr/local/libexec/service-status ;;
reload) exec /usr/local/libexec/service-reload ;;
*) printf '%s\n' 'unsupported operation' >&2; exit 64 ;;
esac
The allowlist limits what the caller can request, but it does not replace authorization. The remote account still needs least-privilege permissions, and each helper must verify that its caller is allowed to perform the operation.
Shell quoting is local to the shell that parses it
Consider this attempt to pass a value containing a semicolon:
value='alpha; touch /tmp/unexpected'
ssh host "printf '%s\\n' '$value'"
The local double quotes group the SSH command as one local argument, but expansion inserts the value into text that the remote shell later parses. The embedded single quote, quote breakouts, command substitutions, backticks, redirections, or newlines can change the remote program. Replacing double quotes with a complicated series of backslashes does not solve the general problem; it creates a custom encoder that must be correct for the exact remote shell and every input byte.
Shell-specific quoting helpers such as Bash printf %q are not transport protocols. Their output is designed for a particular shell grammar and can vary with shell version or mode. It is not a portable guarantee for a remote host running a different shell. If a design depends on such encoding, pin both implementations, document the accepted input domain, and test adversarial bytes. In most operations, fixed remote code plus stdin or a proper file-transfer protocol is simpler and safer.
Standard input and terminal allocation are separate decisions
By default, SSH connects the local standard input to the remote command. That is useful for streaming data or a script, but it means a remote process can consume the input unexpectedly. Use -n only when the remote operation must not read stdin; it redirects SSH’s stdin from /dev/null, so it is wrong for data-pipe patterns. -T disables pseudo-terminal allocation and is appropriate for machine-oriented commands. -t requests a pseudo-terminal for interactive programs, but terminal modes, line discipline, and signal behavior then differ from a byte-preserving pipe.
Do not allocate a TTY merely to make a remote command appear interactive in automation. A PTY can transform line endings, merge or alter stream handling, and affect programs that change behavior when isatty() is true. Conversely, a full-screen remote tool needs a PTY. Make the choice explicit, and test both success and failure paths with the actual command rather than assuming SSH’s default is neutral.
When standard input carries a script, remote diagnostics still arrive on standard error. Capture that stream deliberately in automation, preserve the exit status of SSH, and avoid pipelines that accidentally report the status of a later logger instead of the remote command. In POSIX shell, a pipeline normally returns the status of its last command; in Bash, set -o pipefail changes that rule but is not POSIX. A deployment wrapper should record SSH’s status before cleanup or reporting can overwrite it.
Authentication is not authorization
Successful public-key authentication proves which account the connection uses; it does not make every remote command safe. Use a dedicated service account, restrict its filesystem access, and prefer a server-side forced command or a constrained subsystem for narrow automation. OpenSSH’s authorized-key options and sshd_config policy can restrict forwarding, PTY allocation, source addresses, and command choice, but verify the actual server version and configuration. Do not treat a client-side command allowlist as a server-side security boundary.
Avoid agent forwarding unless the remote host genuinely needs to request signatures from the local agent. A user with sufficient access on the remote host may be able to use a forwarded agent socket while the session is open, even though private key material is not copied to that host. For batch jobs, load a narrowly scoped key or use an approved short-lived credential mechanism, and set host-key verification policy explicitly. Do not suppress host-key checks to make first-run automation convenient; enroll host keys through a trusted channel.
An SSH command can also inherit server-side environment settings through authorized keys or sshd_config. Environment forwarding is restricted by default on many installations and should remain an explicit policy decision. Never assume a local export TOKEN=... is available remotely. If a secret must cross the connection, use a protected stdin protocol and avoid placing it in command-line arguments, shell history, CI logs, or process listings.
Test the boundary with hostile and awkward inputs
Before adopting a remote execution wrapper, exercise values containing spaces, quotes, dollar signs, semicolons, wildcard characters, leading hyphens, tabs, Unicode, and newlines. Test an empty string and a value that begins with -. Use a disposable account and a harmless receiver that records exact input bytes or argument boundaries; do not test injection strings against production commands. Verify the remote exit code, the stderr policy, and whether a TTY was allocated.
Use ssh -vv to diagnose client configuration, authentication, and connection setup, but do not mistake verbose transport logs for proof of the remote argument vector. For a remote helper, log a request identifier and a validated action rather than dumping secret-bearing input. On the server, use narrowly scoped audit logging that records who invoked which helper and whether it completed, without recording passwords, access tokens, or sensitive payloads.
For a script that must support multiple OpenSSH versions or non-OpenSSH clients, define the compatibility boundary instead of assuming every implementation serializes commands identically. Put a version check in deployment diagnostics, test both the oldest and newest supported client/server pair, and prefer a documented copy or RPC protocol when preserving arbitrary argument vectors is a requirement. The central rule remains stable: local quoting cannot protect text after another shell reparses it.
Related:
Sources: