Linux Basic — Safe Temporary Files

4 minute read

Safe Temporary Files

Almost every non-trivial script needs a scratch file or directory — to build a payload, hold an intermediate result, or work around a tool that won’t read stdin. Doing this safely takes about three lines. Doing it unsafely opens a privilege-escalation vulnerability that has bitten famous projects more than once.


The Bad Old Way — /tmp/myscript.$$

For decades, scripts created temp files like this:

# ❌ Insecure
tmp="/tmp/myscript.$$"
echo "data" > "$tmp"
process "$tmp"
rm "$tmp"

Two problems:

  1. Predictable name. $$ is the script’s PID — public information. An attacker can pre-create /tmp/myscript.NNN as a symlink to /etc/passwd and now your script will overwrite /etc/passwd.
  2. Race condition. Between the test “does the file exist?” and the create, an attacker can win the race.

Don’t write this. Ever.


The Modern Way — mktemp

mktemp(1) atomically creates a uniquely-named file (or directory) with safe permissions and prints its path:

tmp=$(mktemp)
echo "$tmp"
# /tmp/tmp.AbCdEf1234

ls -l "$tmp"
# -rw------- 1 ubuntu ubuntu 0 Jun 1 12:00 /tmp/tmp.AbCdEf1234

Note the permissions: rw------- (mode 0600) — only you can read or write. mktemp guarantees:

  • A unique filename (collision-free)
  • Atomic create (no race window)
  • Restrictive permissions

Templates

You can specify a name template so logs are easier to identify. The template needs at least three trailing Xs, which mktemp replaces with random characters:

tmp=$(mktemp /tmp/myscript.XXXXXX)
# /tmp/myscript.f7Hk2L

# Use the system tmp dir but give it a meaningful name
tmp=$(mktemp -t mybuild.XXXXXX)
# /tmp/mybuild.r3Tx8q

Temp directories

For multiple related files, make one tempdir and put them all in it:

tmpdir=$(mktemp -d -t mybuild.XXXXXX)
echo "$tmpdir"
# /tmp/mybuild.aQ7Ymb

touch "$tmpdir/in.txt" "$tmpdir/out.txt" "$tmpdir/log.txt"
ls "$tmpdir"
# in.txt  log.txt  out.txt

Always Clean Up With trap

Creating a temp file is half the job. Removing it on every exit path — success, error, Ctrl+C, killed by a signal — is the other half. The trap builtin runs a command when the shell exits:

tmp=$(mktemp)
trap 'rm -f -- "$tmp"' EXIT

EXIT fires for every exit: normal end, exit 1, set -e killing the script, even an unhandled signal. That single line guarantees cleanup.

For a tempdir:

tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT

A fuller template you can paste at the top of every script:

#!/usr/bin/env bash
set -euo pipefail

tmpdir=$(mktemp -d -t myscript.XXXXXX)
trap 'rm -rf -- "$tmpdir"' EXIT

# ... use $tmpdir freely ...
echo "data" > "$tmpdir/payload"
process "$tmpdir/payload"

When the script ends — for any reason — the tempdir disappears.


What If You Need Multiple Cleanup Actions?

trap is “last writer wins” by default — re-registering a handler replaces the previous one. Use a cleanup function:

cleanup() {
    rm -rf -- "$tmpdir"
    [ -n "${tmpfile:-}" ] && rm -f -- "$tmpfile"
    kill "$bg_pid" 2>/dev/null || true
}
trap cleanup EXIT

Now everything that needs to happen on exit lives in one function.


TMPDIR — Respect the Environment

mktemp chooses where to put the file in this order:

  1. The directory you give it (e.g. mktemp /var/cache/foo.XXXX)
  2. $TMPDIR if set
  3. /tmp as a fallback

Letting the system / user override TMPDIR is good citizenship:

  • CI runners often set $TMPDIR to a per-job scratch area
  • macOS uses a per-user $TMPDIR (try echo $TMPDIR)
  • Containers may have a tmpfs only at a non-/tmp path
TMPDIR="/var/tmp" mktemp -t myscript.XXXXXX
# /var/tmp/myscript.x9P3Lf

Never hard-code /tmp unless you really must.


Permissions and Sensitive Data

mktemp defaults to 0600 — readable only by you. For most cases that’s fine. If you need it tighter:

tmp=$(mktemp)
chmod 0400 "$tmp"     # read-only, even for owner

If the file holds secrets, also consider:

  • Putting it on tmpfs (in-memory; /dev/shm or a tmpfs-mounted /tmp)
  • Shredding it before rm: shred -u "$tmp" (rotational disks; SSDs need different treatment)
  • Setting a tighter umask at the top of the script: umask 077

Cross-Platform Notes

Tool GNU (Linux) BSD (macOS)
mktemp takes a template anywhere template must end with Xs
mktemp -p DIR works ❌ — use mktemp DIR/foo.XXXX
mktemp -t prefix uses $TMPDIR (/tmp/prefix.XXX) uses $TMPDIR (/var/folders/...)
mktemp -d works works

For cross-platform scripts, the most portable form is:

tmp=$(mktemp "${TMPDIR:-/tmp}/myscript.XXXXXX")

Putting It All Together

A safe, copy-paste-ready template:

#!/usr/bin/env bash
set -euo pipefail

# 1. Create a private workspace
work=$(mktemp -d -t myjob.XXXXXX)
trap 'rm -rf -- "$work"' EXIT

# 2. Use it
curl -sSf https://example.com/data.json -o "$work/data.json"
jq '.items[] | .id' "$work/data.json" > "$work/ids.txt"

# 3. Produce final output atomically
out="/var/lib/myjob/ids.txt"
mv -- "$work/ids.txt" "$out"

# trap fires here — tempdir vanishes

That eight-line skeleton handles uniqueness, privacy, cleanup, and atomic delivery.


Common Mistakes

❌ Wrong ✅ Right Issue
tmp="/tmp/$$.txt" tmp=$(mktemp) predictable name = symlink attack
mktemp without trap tmp=$(mktemp); trap 'rm -f -- "$tmp"' EXIT leaks on failure
rm $tmp (unquoted) rm -- "$tmp" spaces/special chars + leading-dash issues
Reuse same name across runs unique name per run via mktemp concurrent runs collide
mktemp /var/foo.XXX then mv $tmp /var/foo.json across FS keep tempfile and target on same filesystem cross-FS mv isn’t atomic

Next → Practice Exercises

Updated: