Bash Traps and mktemp: Cleaning Up Temp Files Even When the Script Dies

A script that writes to /tmp/mydata and deletes it at the end has two bugs, not one: it leaks a stale file every time it crashes, and it hands an attacker a predictable path to race. Both are fixed by two small habits — mktemp for the name and a trap on EXIT for the cleanup.

I still see this pattern in production shell scripts, in CI jobs, and in cron entries that have run untouched for years. It works on the happy path and fails quietly everywhere else. Here is why, and the boilerplate I paste into every non-trivial Bash script instead.

Why a fixed path is two problems

Start with the version almost everyone writes first:

Free · 4 minutes

Would you survive contact with a determined attacker — or an auditor?

Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.

# Fragile: predictable name, leaks on any early exit
tmp=/tmp/mydata
curl -fsS https://example.com/data.json > "$tmp"
process "$tmp" > result.csv
rm "$tmp"

The first problem is the leak. If curl fails, if process exits non-zero, if someone presses Ctrl-C, or if the box reboots mid-run, the rm at the bottom never executes. The file sits in /tmp until something clears it. Do this in a loop or a frequently scheduled job and you slowly fill the filesystem with corpses.

The second problem is worse because it is a security defect, not a tidiness one. The path /tmp/mydata is world-writable-directory territory and entirely predictable. Any local user can create /tmp/mydata first — as a symlink pointing at a file you can write but they cannot, say a config file or, if your script runs as root, /etc/passwd. Your redirect follows the symlink and clobbers the target. This is the classic insecure-temp-file race (CWE-377), and it is exactly the kind of finding that turns up in a pre-commit or CI security baseline once you start scanning for it.

mktemp: an unpredictable name, created safely

mktemp from GNU coreutils solves the naming half. It generates a name with a random component, creates the file atomically before returning, sets restrictive permissions (mode 0600 for files, 0700 for directories), and prints the path it created to standard output so you can capture it:

# A single file — random name, atomic create, mode 0600
tmp="$(mktemp)"                 # e.g. /tmp/tmp.QAd9c3Xk2p

# A directory — mode 0700, and everything you put in it inherits the safety
work="$(mktemp -d)"             # e.g. /tmp/tmp.7fJk1a0Zql

# Pin the location and give it a recognisable prefix.
# A template needs at least three trailing X's; each becomes a random char.
work="$(mktemp -d "${TMPDIR:-/tmp}/myjob.XXXXXXXXXX")"

Two details matter. First, capture the output — mktemp tells you the real name, which you cannot know in advance (that is the point). Second, prefer a directory. If your job needs more than one temp file, mktemp -d once and put everything inside it. You then have a single thing to delete and every file underneath inherits the 0700 parent, so you never repeat the safety argument per file. When no template is given, mktemp defaults to tmp.XXXXXXXXXX in $TMPDIR or /tmp; a template containing a slash is used as an absolute or relative path verbatim.

This closes the race. An attacker cannot pre-create a file whose name they cannot predict, and because mktemp creates it with O_EXCL semantics it will not silently reuse an existing entry. That property is the same reason it is the right place to stage anything sensitive — a decrypted secret, a short-lived token, an API key you are about to hash or rotate — rather than a path anyone on the host can guess.

trap on EXIT: the only cleanup that always runs

mktemp fixed the name; it did nothing for the leak. Scattering rm calls at each exit point is how the leak survives — you always miss one, usually the error path you did not think about. The fix is to register cleanup once and let the shell guarantee it runs.

Bash’s trap ... EXIT runs its command whenever the shell exits: normal completion, explicit exit, an error under set -e, or the end of the script. One registration covers every ordinary way the script can finish. What EXIT does not cover is being killed by a signal that has no handler: an un-trapped SIGINT (Ctrl-C) or SIGTERM (a plain kill) terminates the shell without running the EXIT trap. So you trap those too — but not by pointing them at the cleanup function directly.

The tempting line is trap cleanup EXIT INT TERM. It half-works and has a sharp edge: after a signal handler returns, Bash resumes the script where it was interrupted unless the handler itself exits, and you also get cleanup running twice. The robust idiom instead traps the signals to a bare exit, which then triggers the single EXIT handler exactly once, with a correct exit code:

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

# Create the workspace BEFORE installing the trap, so a failure in mktemp
# itself can't leave a trap pointing at an unset variable.
work="$(mktemp -d "${TMPDIR:-/tmp}/myjob.XXXXXXXXXX")"

cleanup() {
  rm -rf -- "$work"
}

trap cleanup EXIT      # normal exit, error exit under set -e, and end of script
trap 'exit 130' INT    # Ctrl-C  -> exit 128+2,  which fires EXIT once
trap 'exit 143' TERM   # kill    -> exit 128+15, which fires EXIT once

# --- real work below; everything stays inside "$work" ---
curl -fsS https://example.com/data.json > "$work/data.json"
process "$work/data.json" > "$work/out.csv"
mv "$work/out.csv" ./out.csv

Read the ordering deliberately. work is assigned first; if mktemp fails, set -e exits before any trap exists and there is nothing to clean, which is correct. The trap is installed immediately after, before any real work, so from that point on there is no exit path — success, error, or signal — that leaves $work behind. The exit codes 130 and 143 are the conventional 128 + signal values, so callers and CI still see that the job was interrupted rather than a clean pass.

The edges that bite later

A few things are worth knowing before you rely on this in anger.

  • Keep cleanup idempotent. rm -rf -- "$work" is safe to run more than once, which matters if you ever extend the handler. It is the same discipline that makes a POST safe to retry — running the operation twice must not do harm.
  • Always use rm -rf --. The -- stops a name that happens to begin with a dash being read as an option, and quoting "$work" guards against spaces. Never build the path with an unquoted glob.
  • Traps do not cross into subshells. A trap set in the parent is not active inside $( ... ) or an explicit ( ... ) subshell. If a subshell creates its own temp files, it needs its own trap.
  • Do not hardcode /tmp. Honour $TMPDIR as the template above does; on many systems that points at a per-service, auto-cleaned directory, and some hardened hosts mount /tmp noexec.

None of this is clever. It is four lines and a habit. But it is the difference between a script that behaves the same whether it succeeds, errors, or is killed — and one that quietly leaves evidence, and sometimes an opening, every time it does not reach the bottom.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Free interactive tool

Interactive deadline calculator

Check which regulations apply to you and when

Regulation across the EU, UK, US and Asia-Pacific has moved considerably in the past eighteen months, and several headline dates have shifted more than once. Twelve questions, about three minutes.

Results are shown on screen — no email required. A dated summary is available to download, and can be sent on if that's more useful. What we do with your answers.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.

Full Governance by Sixteen Pillars

Govern your business. Prove your compliance.

A board assurance cockpit for EU-regulated financial firms — tamper-evident, hash-chained proof of governance across DORA, GDPR, NIS2, ISO 27001, the EU AI Act and MiCA. In development.

See what's coming