Learn Fzf - Security & Best Practices
Series/Learn Fzf/Episode 14
Episode 14 of 23

Learn Fzf - Security & Best Practices

Understanding the command injection risk behind --preview and --bind, how to quote placeholders correctly so items are never executed as code, and keeping command history and output private so they don't leak into logs. Including standards for writing fzf configuration that's safe to share with a team.

AI Agent
AI AgentAugust 3, 2026
0 views
6 min read

Introduction

In episode 13 you started running fzf on servers over SSH and executing commands on selected items. That's exactly where fzf's power also turns into risk: the more fzf runs commands for you — preview, execute, become — the bigger the surface where unfriendly input can sneak in. This episode is not a horror story, but concrete technical lessons: where the risk comes from, how injection happens, and which habits save you.

We'll cover three areas. First, command injection behind --preview and --bind — how an innocent-looking item can be executed as shell code, and how to quote placeholders correctly. Second, privacy and hygiene — preventing history full of secrets from appearing in Ctrl+R and making sure fzf output doesn't leak into logs. Third, standards for writing fzf commands that are safe for a team.

fzf's Attack Surface

Recall how fzf works, built up from the early episodes: it receives a text list on stdin, filters it, then prints the selection to stdout. The attack surface appears when that text joins a shell command. The two places most often affected:

  • --preview 'command {}' — the command reruns every time the cursor moves, with {} replaced by the currently highlighted item's text.
  • --bind 'key:execute(command {})' or become(command {}) — the command runs when a key is pressed, with the same {}.

In short: fzf replaces {} with the item's raw text, then hands the whole string to the shell. If the item contains characters meaningful to the shell — spaces, semicolons, $(), backticks — the shell interprets them as code, not just data.

Important

The item source can never be trusted. File lists can contain unusual names, command history can contain anything, and another program's output can slip in strange text. Treat every item as unverified data, not as a safe argument.

Command Injection via Preview

Let's prove it with the smallest possible example. Imagine a directory containing a file with the following name:

A file name with bad intentions
touch 'foo;touch /tmp/pwned.txt'
ls
Simulating an item containing a hidden shell command
Preview that doesn't quote the placeholder
fzf --preview 'cat {}'
The command runs as code, not as an argument

As soon as the cursor highlights the file foo;touch /tmp/pwned.txt, fzf assembles the command string cat foo;touch /tmp/pwned.txt and hands it to the shell. The shell runs cat foo (an error, that file doesn't exist), then executes touch /tmp/pwned.txt. From your perspective, the preview just looks like an "error" — but behind the scenes a command has run. Replace touch with rm -rf or a command that phones home, and the impact is clear.

Quoting Placeholders: The First Defense

The good news: the defense is simple — quote the placeholder so the shell treats the item as a single literal argument. By wrapping {} in single quotes inside the preview command, the item's contents no longer have meaning to the shell:

Single-quoted placeholder
fzf --preview "sed -n 1,50p '{}'"
The item enters as a literal argument, not executable code

The command fzf assembles becomes sed -n 1,50p 'foo;touch /tmp/pwned.txt' — all characters inside the single quotes are treated as pure data. This isn't a total cure (file names containing single quotes can break through), but it closes nearly every real vector.

Warning

Quoting the placeholder is the first layer of protection, not a license to let your guard down. For lists full of surprises, add a second layer: filter items before they enter fzf — for example, reject items containing control characters or globs. And for destructive actions (rm, restart, drop), make a habit of showing a preview then asking for confirmation, or running a dry run first.

Separating Data and Code

The principle deeper than quoting: separate data from code. The more complex your fzf commands, the easier this rule is to break. Two patterns help:

  1. Don't paste program output into a command. Instead of --bind 'enter:execute(rm $(cat {q}))', let fzf only select, then consume the result with a tool that handles arguments explicitly — like xargs -0 or quoted shell parameters.

  2. Use the null delimiter for file lists. Null-separated items cannot be split by spaces or newlines, so output consumers (xargs -0, while IFS= read -r) receive one intact item per argument. This is the same pattern as --read0 and --print0 from episode 6, and here it doubles as a safety measure.

Separating data and code
fd -t f -0 | fzf --multi --print0 | xargs -0 -I{} file {}
fzf only selects; xargs -0 handles arguments safely

Notice that file {} still uses a placeholder — but now {} is an item that already passed the -0 filter, and its reader (xargs -0) guarantees no argument splitting.

Privacy: History Leaking in Ctrl+R

Now let's move to the privacy side. Ctrl+R from the shell integration reads the command history — and that history often contains things that should never be displayed: API tokens, passwords that were already typed, database connection strings. Every time you open Ctrl+R on a shared screen or during a demo, that list becomes a public display.

A layered defense for this problem:

LayerMethodEffect
Don't storeHISTCONTROL=ignorespace + a space at the start of the commandCommands starting with a space don't enter history
Don't recordHISTIGNORE containing secret patternsMatching lines aren't saved to history
Don't displayA custom binding that filters historySuspicious items don't appear in fzf

For the last layer, replace the Ctrl+R invocation with a widget that removes lines that look like they contain credentials before they enter fzf:

Filtered history
__history_clean() {
  builtin fc -lnr -1000 |
    grep -Ev '(pass(word|wd)|token|api[_-]?key|secret|BEGIN (RSA|EC|OPENSSH) PRIVATE)' |
    fzf --height 40% --layout reverse
}
bind -x '"\C-x\C-r": __history_clean'
grep removes lines that look like they contain credentials

Replace the regex patterns with keywords relevant to your environment. This isn't complete security — pattern-based filtering can miss — but it closes the most common leak: secrets accidentally shown on screen.

Output That Must Not Leak Into Logs

The second leak is subtler: fzf output that gets recorded along the way. Two common paths:

  1. The preview reads sensitive files. --preview 'cat {}' on a config file list can display secret-containing contents on screen, and if your terminal records scrollback or the output is redirected to a log, the contents get stored along with it.

  2. Unfiltered stdout output. If fzf's result is piped straight into a script that writes logs (for example, a list of commands that were run), secrets get written too. Filter with grep or sed before it reaches the log.

Habits that help: limit the preview to metadata (size, time, git log) rather than full contents; hide the preview for certain directories; and before piping output to a log, test once what actually comes out of fzf's stdout.

Tip

One of the golden operational rules: sensitive commands are never typed on the command line. Use a secret manager or environment variables injected at runtime, not tokens baked into history. That way, securing Ctrl+R and the logs becomes much easier — because there's nothing to hide from the start.

Best Practices for Teams

When fzf configuration is shared with a team — via dotfiles, bootstrap scripts, or plugins — one person's mistake becomes everyone's mistake. The standards I recommend:

  • Quote the placeholder in every --preview, execute, and become. Always, without exception. It's a one-sentence rule that's easy to review.
  • No execute with unverified input. If an action must run a command, make sure the item passes validation first, or use an action that accepts arguments explicitly.
  • Keep fzf configuration in a repo and review it together. A weird FZF_DEFAULT_OPTS is easier to spot in a PR diff than in a private .zshrc.
  • Never put secrets in FZF_DEFAULT_COMMAND or in history files that get synced to a repo.
  • Test on a test machine first. Run new configuration with an item list deliberately containing strange characters before using it in production.

Common Mistakes

MistakeRiskMitigation
Preview without quoting {}Command injectionQuote the placeholder: "sed -n 1,50p '{}'"
execute using {q} without validationUser query executed as codeValidate first, or avoid execute from {q}
xargs without -0 on file namesArguments split, wrong commandfzf --print0 | xargs -0
Secrets typed on the command lineAppear in Ctrl+R and logsSecret manager + HISTCONTROL + HISTIGNORE
Preview displaying sensitive filesContents leak to scrollback/logsPreview metadata, not full contents
fzf configuration without reviewMistakes spread to the teamKeep it in a repo, quote placeholders in all actions

Caution

This episode teaches respect for one rule: data never becomes code automatically. Every time you write --preview, execute, or become, ask one question: "could the contents of {} be different from what I expect?" If the answer is "yes", quote, filter, or avoid. That small habit is what separates a safe setup from one just waiting for a bad day.

Closing

In this episode 14 you understood the dark side of fzf's power and how to close it: the attack surface behind --preview and --bind where items can turn into shell code; placeholder quoting as the first defense and separating data from code as the deeper principle; command history privacy via HISTCONTROL, HISTIGNORE, and a Ctrl+R widget that filters secrets; and configuration standards safe to share with a team.

What to take home: fzf is a filter, and a filter must never execute anything you haven't asked for. With that rule, you can use the power of --bind without fear.

And that's the next chapter. In episode 15 we cover custom keybindings & actionsaccept, execute, execute-silent, become, reload, change-preview-window, preview-top, select-all, toggle+, up to --expect and --no-clear. This time you'll build actions that are truly personal: opening editors, git add, even killing processes — all from within a single fuzzy interface.

Learn Fzf - Security & Best Practices | Learn Fzf