# Cron Security: Hardening Scheduled Jobs on Linux

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-security

> Harden cron on Linux: least-privilege jobs, cron.allow, crontab permissions, writable scripts, PATH and wildcard risks, secrets, and auditing for persistence.

**Securing cron means running each job as the least-privileged account that can do the work, making sure nothing a privileged job runs, reads, or writes can be changed by a less-privileged account, keeping secrets out of crontab lines, and watching cron's files, because attackers use cron to persist.** The cron daemon checks the crontab file and nothing else: not the scripts it starts, the directories they live in, or the `PATH` it searches. Those are what you have to check yourself.

This guide was checked on October 25, 2026 against cronie 1.7.2 (Fedora, RHEL and derivatives, Arch), Debian's cron 3.0pl1-197 (Debian 13; Ubuntu packages the same cron), BusyBox 1.37.0 (Alpine), audit 4.2.1, and systemd 262.

## What Cron Checks Before It Loads a Crontab

|                                 | cronie 1.7.2                                                                                  | Debian cron 3.0pl1                                                                       | BusyBox crond 1.37.0            |
| ------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------- |
| User crontab                    | Owned by root or that user; not executable; not group- or other-writable; one hard link       | Owned by root or that user; mode exactly `0600`; not a symlink; one hard link            | Owned by root; mode not checked |
| `/etc/crontab`, `/etc/cron.d/*` | Owned by root; not executable; not group- or other-writable                                   | Owned by root, and so is a symlink to it; not group- or other-writable; one hard link    | Not read                        |
| A file that fails               | Skipped and logged as `BAD FILE MODE`, `WRONG FILE OWNER`, `NOT REGULAR`, or `BAD LINK COUNT` | Skipped and logged as `INSECURE MODE`, `WRONG FILE OWNER`, or `NUMBER OF HARD LINKS > 1` | Skipped without a log line      |

Sources: cronie [database.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/database.c#L86-L109), Debian [database.c](https://sources.debian.org/src/cron/3.0pl1-197/database.c/#L320-L430), BusyBox [crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L440).

On cronie, make sure `CRONDARGS` in `/etc/sysconfig/crond` ([cronie.systemd](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/cronie.systemd)) doesn't include `-p`, which "allows Cron to accept any user set crontables" and turns these checks off ([cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html)).

That is where the checks end. Debian's source notes that it doesn't check parent directories and assumes "root will handle responsible" ([database.c](https://sources.debian.org/src/cron/3.0pl1-197/database.c/#L417-L422)). `run-parts`, which runs `/etc/cron.daily` and its siblings, requires only that each file be a regular, executable file ([run-parts.c](https://sources.debian.org/src/debianutils/5.23.2/run-parts.c/#L621-L622)). A root-owned crontab line that runs `/opt/app/bin/cleanup` is only as safe as that file and every directory above it.

## Run Each Job as a Dedicated, Unprivileged Account

A job running as root turns every bug in it, and every file it trusts, into a root compromise. Give each job its own system account with access to only what it touches, and schedule it from a file in `/etc/cron.d`, whose lines name the account to run as:

```bash
sudo useradd --system --create-home --home-dir /var/lib/report-export \
  --shell /usr/sbin/nologin report-export

printf '%s\n' '15 3 * * * report-export /opt/report-export/bin/run' |
  sudo install -o root -g root -m 0644 /dev/stdin /etc/cron.d/report-export
```

`--system` skips the home directory unless you add `--create-home` ([useradd(8)](https://man7.org/linux/man-pages/man8/useradd.8.html)). Cron starts jobs there, and cronie won't run a job whose home directory is missing ([security.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/security.c#L261-L270)). The `nologin` shell blocks logins, not cron jobs: cronie and Debian run jobs with `/bin/sh` unless the crontab sets `SHELL`, and BusyBox with crond's own shell ([cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L297), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L358), [BusyBox](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L1059-L1061)). On Alpine, which has no `/etc/cron.d`, use `/etc/crontabs/report-export`. Under systemd, the job is a timer whose service sets `User=`; see [systemd timers](https://cronradar.com/blog/systemd-timers).

Job scripts can refuse to run as root, as the template in [shell script best practices for cron](https://cronradar.com/blog/cron-shell-script-best-practices) does. Keep service accounts away from `crontab`, too, because systemd's sandboxing doesn't follow work handed to cron: `NoNewPrivileges=` "has no effect on processes potentially invoked on request of them through tools such as at, crontab, systemd-run" ([systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#NoNewPrivileges=)).

## Control Who Can Schedule Jobs

`/etc/cron.allow` and `/etc/cron.deny` decide who may run the `crontab` command. If `cron.allow` exists, only users listed in it can; otherwise, everyone not listed in `cron.deny` can. When neither file exists, cronie allows only root ([crontab(1)](https://man7.org/linux/man-pages/man1/crontab.1.html)) and Debian allows everyone ([misc.c](https://sources.debian.org/src/cron/3.0pl1-197/misc.c/#L489-L494)). Root is always allowed. For an allowlist:

```bash
printf '%s\n' deploy | sudo install -o root -g root -m 0644 /dev/stdin /etc/cron.allow
```

Keep it readable: Debian's cron denies every user if `cron.allow` or `cron.deny` isn't readable by everyone or by the `crontab` group ([crontab(1)](https://manpages.debian.org/trixie/cron/crontab.1.en.html)). BusyBox's crontab reads neither file ([crontab.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crontab.c)); on Alpine, ordinary users need the setuid helper from the `busybox-suid` package to run it ([bbsuid.c](https://gitlab.alpinelinux.org/alpine/aports/-/blob/3.22-stable/main/busybox/bbsuid.c)).

These files have limits:

- **They gate the command, not the jobs.** Restricting `crontab` "has no effect on an existing crontab of a user" ([crontab(1)](https://man7.org/linux/man-pages/man1/crontab.1.html)). Remove a denied user's crontab with `sudo crontab -u alice -r`.
- **They don't cover `at`.** `at` has its own `/etc/at.allow` and `/etc/at.deny`; if neither exists, only root may use it ([at.allow(5)](https://manpages.debian.org/trixie/at/at.allow.5.en.html)).
- **They don't cover systemd user timers.** systemd's default polkit policy lets any user enable lingering for their own account ([policy](https://github.com/systemd/systemd/blob/v262.1/src/login/org.freedesktop.login1.policy#L127-L135)), which starts their user manager, and their timers, at boot ([loginctl(1)](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html)). Lingering accounts appear as files in `/var/lib/systemd/linger/` ([logind-user.c](https://github.com/systemd/systemd/blob/v262.1/src/login/logind-user.c#L728)).

PAM, by contrast, applies when jobs run. Both daemons check the account's PAM account stack before each job, except cronie for root's system-crontab jobs ([Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L223-L233), [cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/security.c#L127-L133)), so an expired account's jobs don't start ([pam_unix](https://github.com/linux-pam/linux-pam/blob/v1.7.1/modules/pam_unix/passverify.c#L309-L312)).

## Nothing a Root Job Uses Should Be Writable by Another Account

If root's job runs a script that `deploy` can edit, `deploy` can run anything as root. The same goes for any directory above that script, a file it sources, or a missing script in a directory someone else can write to. A procedure in ATT&CK's [cron technique](https://attack.mitre.org/techniques/T1053/003/), campaign C0064, did this: the intruder edited a script a root crontab ran, then restored the file's modification time so it looked untouched.

Writes count too. If root appends to `/var/log/app/sync.log` and `appuser` owns `/var/log/app`, `appuser` can replace the log with a symlink to any file. The kernel's `protected_symlinks` setting blocks this only in sticky, world-writable directories such as `/tmp`, not when "the directory owner matches the symlink's owner" ([kernel docs](https://docs.kernel.org/admin-guide/sysctl/fs.html#protected-symlinks)).

This script, for cronie, Debian, and Alpine systems, lists the paths in an account's cron entries, and their parent directories, that another account could change. For root it also reads `/etc/anacrontab` and the periodic directories:

```bash
#!/bin/sh
# cron-writable-paths.sh [account]: paths an account's cron jobs use that another
# account could change. Run as root. Read-only.
account=${1:-root}
id "$account" > /dev/null || exit 1

# Absolute paths in commands that crontab file $2 runs as $account.
# $1: user, system (/etc/crontab, /etc/cron.d), or anacron.
paths_in() {
  [ -f "$2" ] || return 0
  awk -v kind="$1" -v who="$account" -v q="'" '
    /^[[:space:]]*(#|$)/ { next }                               # comment or blank
    /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/ { next }  # variable
    {
      f = ($1 ~ /^-?@/) ? 2 : 6                # @daily-style lines have one time field
      if (kind == "anacron") f = 4             # period, delay, job id, command
      if (kind == "system") { if ($f != who) next; f++ }
      for (i = f; i <= NF; i++) {
        w = $i; gsub(q, "", w); gsub(/["`]/, "", w); gsub(/[;|&<>()=]/, " ", w)
        n = split(w, part, " ")
        for (j = 1; j <= n; j++) if (part[j] ~ /^\/[^\/]/) print part[j]
      }
    }' "$2"
}

# Show the path and its parents (and a symlink's target) if owned by someone other
# than root or $account, or writable by group or others.
check() {
  for p in "$1" "$(readlink -f "$1" 2>/dev/null)"; do
    while [ -n "$p" ]; do
      find "$p" -maxdepth 0 \( -type f -o -type d \) \
        \( ! -user root ! -user "$account" -o -perm /022 \) -exec ls -ld {} \; 2>/dev/null
      parent=$(dirname "$p")
      [ "$parent" = "$p" ] && break
      p=$parent
    done
  done
}

{
  for f in /var/spool/cron/"$account" /var/spool/cron/crontabs/"$account" /etc/crontabs/"$account"; do
    paths_in user "$f"
  done
  for f in /etc/crontab /etc/cron.d/*; do paths_in system "$f"; done
  if [ "$account" = root ]; then
    paths_in anacron /etc/anacrontab
    for f in /etc/cron.hourly/* /etc/cron.daily/* /etc/cron.weekly/* /etc/cron.monthly/* /etc/periodic/*/*; do
      [ -e "$f" ] && printf '%s\n' "$f"
    done
  fi
} | sort -u | while IFS= read -r path; do check "$path"; done | sort -u
```

No output means nothing was found. A line such as `drwxrwxr-x 2 deploy deploy 4096 Oct 20 11:02 /opt/app/bin` means `deploy` controls what root runs. The script sees only literal absolute paths, not paths built from variables or what the scripts themselves run. For those, `namei -l /opt/app/bin/cleanup` prints the owner and mode of every component ([namei(1)](https://man7.org/linux/man-pages/man1/namei.1.html)). Fix a finding by making the path root-owned and not group- or other-writable, or, usually better, by running the job as the account that owns those files.

## PATH Lookups and Wildcards in Privileged Jobs

### Relative Command Names

A command written without a slash is looked up in each `PATH` directory in turn ([POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html#tag_08_03)), so a writable directory early in root's `PATH` lets its owner shadow `tar` or `run-parts`. ATT&CK tracks this as [T1574.007](https://attack.mitre.org/techniques/T1574/007/). Debian's `/etc/crontab` puts `/usr/local/sbin:/usr/local/bin` first ([crontab.main](https://sources.debian.org/src/cron/3.0pl1-197/debian/crontab.main/)), and an empty element, as in `::` or a trailing colon, means the current directory. Check every directory on the `PATH` a root job uses:

```bash
p='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'   # the job's PATH
echo "$p" | tr ':' '\n' | xargs -n 1 namei -l
```

Every component should be root-owned with no group or other `w`. In root's jobs, use absolute command paths and set `PATH` to root-owned directories.

### Globs in Directories Other Accounts Write To

When `*` expands, a file name that begins with `-` becomes an option to the command, the argument injection described in [CWE-88](https://cwe.mitre.org/data/definitions/88.html). Some options run programs: GNU tar 1.35 can execute a command at each checkpoint ([manual](https://www.gnu.org/software/tar/manual/html_node/checkpoints.html)). A root job running `tar *` in an upload directory lets anyone who can create files there choose tar's options:

```text
# Risky: runs as root and expands * in a directory other accounts can write to
0 3 * * * root cd /srv/uploads && tar -czf /var/backups/uploads.tgz *
# Safer: no glob, so no file name can become an option
0 3 * * * root tar -C /srv/uploads -czf /var/backups/uploads.tgz .
```

Where a glob is unavoidable, write `./*`, or put `--` before it, which ends option parsing for utilities that follow POSIX [Guideline 10](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html#tag_12_02). Better still, run the job as the directory's owner. For temporary files, use `mktemp` rather than fixed names in `/tmp`, or give a systemd service its own `/tmp` with [`PrivateTmp=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateTmp=).

## Keep Secrets Out of Crontab Lines

A secret typed into a crontab line, such as `mysqldump -pS3cret` or `curl -H "Authorization: Bearer ..."`, is copied to several places:

| Where it goes                           | Who can read it                                                                                                                                                                                                                                                   |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The running job's command line          | Every user, through `ps`; cronie and Debian start each job as `sh -c 'the command'`, and `/proc/pid/cmdline` is readable by all unless `/proc` is mounted with [`hidepid`](https://man7.org/linux/man-pages/man5/proc.5.html)                                     |
| cron's log of each job                  | cronie and Debian log `CMD (the command)` by default; the system journal is readable by members of `systemd-journal`, `adm`, and `wheel` ([journalctl(1)](https://www.freedesktop.org/software/systemd/man/latest/journalctl.html))                               |
| Cron mail, for crontab variables        | Whoever receives it: cronie and Debian add an `X-Cron-Env` header for every variable ([cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L539-L540), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L597)) |
| Crontab backups and configuration repos | Anyone with access to them                                                                                                                                                                                                                                        |

BusyBox crond also logs each command at its default log level ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L959-L960)). Find likely secrets already sitting in crontabs:

```bash
sudo grep -r -n -i -E '(pass(word|wd)?|token|secret|api[_-]?key)[[:space:]]*=|--password|bearer |://[^/@[:space:]]+:[^/@[:space:]]+@' \
  /etc/crontab /etc/cron.d /var/spool/cron /etc/crontabs 2>/dev/null
```

`-r` lets grep, as root, read the spool directories, which your own shell couldn't glob. It misses tokens without a telltale name. On Debian it also searches pending `at` jobs, which keep the environment they were submitted with ([at(1)](https://manpages.debian.org/trixie/at/at.1.en.html)).

### Where the Secret Goes Instead

Put it in a file owned by the job's account with mode `0600` and have the job's wrapper load it, as the wrapper in [cron environment variables](https://cronradar.com/blog/cron-environment-variables) does. Hand it to programs through a file or the environment, not arguments: a process's environment passes a ptrace access check before anyone can read it ([proc_pid_environ(5)](https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html)), while its command line is public. Avoid `set -x`, which writes each command to standard error after expansion ([POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_26)), values included, and cron mails standard error. Set `umask 077` or `027` in the script, too: neither cronie nor Debian's cron sets one for jobs, and neither project's systemd unit sets `UMask=`, so jobs start with systemd's default `0022` ([systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#UMask=)) unless a PAM module changes it.

On a systemd timer, use credentials, not `Environment=`, which is exposed to unprivileged clients over D-Bus ([systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Environment=)). `LoadCredential=` (systemd 247+) copies a file into a directory only the unit's user and root can read, named in `$CREDENTIALS_DIRECTORY`; the source can stay `root:root 0600`, since only the service manager reads it ([systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#LoadCredential=ID:PATH)):

```ini
[Service]
Type=oneshot
User=report-export
UMask=0077
LoadCredential=db-password:/etc/report-export/db-password
ExecStart=/opt/report-export/bin/run
```

The job reads `"$CREDENTIALS_DIRECTORY/db-password"`. `LoadCredentialEncrypted=` takes a file made with [`systemd-creds encrypt`](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html), so the secret isn't stored in plaintext.

### Monitoring Check-Ins Carry No Secrets

A check-in needs only a monitor key, the schedule, and perhaps a run ID or short failure message, never job data or credentials. With CronRadar, the one secret is the application API key. There's no CronRadar SDK or auto-discovery for cron, so each job sends its own HTTP check-in, as in the [dead man's switch wrapper](https://cronradar.com/blog/cron-dead-mans-switch). Keep the key in a file only the job's account can read and pass it with `--config`, not `--user`: curl hides a `--user` argument from process listings, but it is "still visible for a moment" and such data "should be retrieved from a file instead" ([curl](https://curl.se/docs/manpage.html#-u)):

```bash
# In the job's wrapper, after the work succeeds. The config file is mode 600,
# owned by the job's account, and holds one line: user = "ck_app_12345_abcdef:"
curl --fail --silent --show-error --max-time 5 --output /dev/null \
  --config /etc/report-export/cronradar.curl --get \
  --data-urlencode "schedule=15 3 * * *" \
  https://cron.life/api/v1/ping/report-export || true
```

The key travels in the Basic auth header, not the URL, which keeps it out of proxy logs ([API reference](https://docs.cronradar.com/api-reference)). CronRadar reads the schedule in UTC.

## Cron as a Persistence Mechanism: Audit and Detect

ATT&CK lists cron abuse as [T1053.003](https://attack.mitre.org/techniques/T1053/003/) under Execution, Persistence, and Privilege Escalation (ATT&CK v20), next to [at](https://attack.mitre.org/techniques/T1053/002/) and [systemd timers](https://attack.mitre.org/techniques/T1053/006/).

### Inventory Every Schedule

The read-only script in the [crontab guide](https://cronradar.com/blog/crontab-guide#list-every-cron-job-on-a-machine) prints every crontab, anacron, the periodic directories, and system timers. Add what it doesn't cover:

```bash
sudo atq                                  # every user's at jobs; sudo at -c 12 prints job 12
ls /var/lib/systemd/linger/               # accounts whose user timers run without a login
sudo systemctl --user --machine=alice@.host list-timers --all   # alice's timers, if her manager runs
```

Run by root, `atq` lists everybody's jobs ([at(1)](https://manpages.debian.org/trixie/at/at.1.en.html)); `--machine=alice@.host` connects to that user's service manager ([systemctl(1)](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html)). Save the output with content hashes of the files, and compare on a schedule:

```bash
sudo find /etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly \
  /etc/cron.monthly /etc/anacrontab /var/spool/cron /etc/crontabs -type f \
  -exec sha256sum {} + 2>/dev/null | sort -k 2 > "cron-$(hostname)-$(date +%F).sha256"
diff cron-web1-2026-10-18.sha256 cron-web1-2026-10-25.sha256
```

Compare hashes, not timestamps, which C0064 reset, and add the scripts root's jobs run. Keep baselines on another host, where root on this one can't rewrite them. [AIDE](https://aide.github.io/) (0.19.5, checked October 25, 2026) does the same for any paths you configure. When the intended crontabs live in a repository, [crontab version control](https://cronradar.com/blog/crontab-version-control) shows how to detect drift against it.

### Record Every Change With auditd

auditd records who changed a file and with which program, including edits that bypass `crontab`. In audit 4.2.1, `auditctl` calls the `-w path -p wa` watch form "deprecated due to poor system performance" ([auditctl(8)](https://github.com/linux-audit/audit-userspace/blob/v4.2.1/docs/auditctl.8)). Write syscall rules with `path=` for a file and `dir=` for a directory tree ([audit.rules(7)](https://github.com/linux-audit/audit-userspace/blob/v4.2.1/docs/audit.rules.7)), in `b32` and `b64` pairs like the project's [sample rules](https://github.com/linux-audit/audit-userspace/blob/v4.2.1/rules/30-pci-dss-v31.rules). This writes rules for the paths that exist on the host:

```bash
for p in /etc/crontab /etc/anacrontab /etc/cron.allow /etc/cron.deny /etc/at.allow /etc/at.deny \
         /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \
         /var/spool/cron /var/spool/at /etc/crontabs /etc/systemd/system /var/lib/systemd/linger; do
  if [ -d "$p" ]; then field="dir=$p/"; elif [ -e "$p" ]; then field="path=$p"; else continue; fi
  for arch in b32 b64; do
    echo "-a always,exit -F arch=$arch -F $field -F perm=wa -F key=cron"
  done
done | sudo tee /etc/audit/rules.d/50-cron.rules > /dev/null
sudo augenrules --load
sudo auditctl -l -k cron
```

`perm=wa` records writes and attribute changes, and `augenrules` merges `/etc/audit/rules.d/*.rules` and loads them ([augenrules(8)](https://github.com/linux-audit/audit-userspace/blob/v4.2.1/docs/augenrules.8)). Review events with `sudo ausearch -k cron -i -ts today` ([ausearch(8)](https://github.com/linux-audit/audit-userspace/blob/v4.2.1/docs/ausearch.8)). Root can unload rules unless the configuration is locked with `-e 2`, which takes a reboot to undo, so forward audit logs off the host. Users' timers in `~/.config/systemd/user/` aren't covered; the linger directory shows who can run them unattended.

### Read Cron's Own Logs

On cronie and Debian, the `crontab` command logs under the syslog identifier `crontab` as `(invoking user) ACTION (target user)`, with `LIST`, `DELETE`, `BEGIN EDIT`, `REPLACE`, or `END EDIT`, and `AUTH (crontab command not allowed)` for a denied user ([cronie crontab.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L1039), [Debian crontab.c](https://sources.debian.org/src/cron/3.0pl1-197/crontab.c/#L979)). The daemon logs `RELOAD` when a crontab it already loaded changes, and the check failures from the table above:

```bash
journalctl -t crontab --since "7 days ago"                  # (root) REPLACE (alice)
journalctl -u crond --since "7 days ago" | grep -E 'RELOAD|BAD |WRONG|INSECURE|LINK|REGULAR|ORPHAN'
```

Use `-u cron` on Debian and Ubuntu and `-u cronie` on Arch. A file written straight into `/etc/cron.d` or the spool leaves no `crontab` line, which is why the audit rules matter. On cronie, an entry in a system crontab or root's crontab that begins with `-` isn't logged when it runs ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html), [entry.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L136-L148)), so look for such lines.

## Frequently Asked Questions

### What is cron.allow?

`/etc/cron.allow` lists the users, one per line, allowed to run the `crontab` command. When it exists, everyone else is refused and `/etc/cron.deny` is ignored; root is always allowed. It doesn't stop crontabs already installed, `at` jobs, or systemd user timers, and BusyBox ignores it.

### Should cron jobs run as root?

Only when the work needs root. Run everything else as a dedicated system account. When a job must run as root, its script, the files it uses, and every directory above them and on its `PATH` should be root-owned and not writable by group or others.

### How do attackers use cron for persistence?

They add an entry to a crontab, drop a file into `/etc/cron.d` or a periodic directory, or edit a script an existing root job runs, so the schedule looks unchanged. MITRE ATT&CK tracks this as T1053.003. auditd rules and a hash baseline of cron's files catch the first two; for the third, keep root's scripts unwritable by others and hash them too.

### How do I audit all cron jobs on a server?

List user crontabs in the spool directory, `/etc/crontab`, `/etc/cron.d`, the periodic directories, `/etc/anacrontab`, `atq` as root, system timers, and user timers of accounts in `/var/lib/systemd/linger`. Check what root's jobs run for paths others can modify, and diff the results on a schedule against a baseline kept off the host.

### Where should a cron job keep its secrets?

In a file owned by the job's account with mode `0600`, loaded by the job's wrapper, or in a systemd credential for a timer. Not in the crontab line, which shows up in `ps`, cron's log, and backups, and not in a crontab variable, which cronie and Debian copy into cron mail headers. Pass it to programs through files or the environment, not arguments.
