Guide · PHP startup persistence

A PHP startup directive runs before WordPress — and before every WordPress security plugin.

You deleted the malicious file and it came back within seconds. Or your scanner reports the site clean while visitors still get redirected. Or you opened a .user.ini you did not create and found an auto_prepend_file line pointing at something unfamiliar. All three are the same mechanism: PHP can be told to load a file before the requested script runs, which means before WordPress exists, which means before anything inside WordPress can see it. This guide covers how it works, where to look, how to tell a real one from Wordfence’s legitimate use of the same directive, and how to remove it in an order that actually holds.

Symptom
Malware that returns instantly, or a clean scan alongside live symptoms
Mechanism
auto_prepend_file in .user.ini, .htaccess, or php.ini
Boundary
PHP configuration — below WordPress and every plugin inside it
Complication
Wordfence uses the same directive legitimately

How it works

What auto_prepend_file in .user.ini actually does

auto_prepend_file is a standard PHP configuration directive. It names a file that PHP parses automatically before the script that was actually requested. Point it at a payload and that payload executes on every single PHP request to the site — before index.php, before wp-load.php, before WordPress has defined a constant, let alone loaded a plugin.

.user.ini is how that directive gets set without server access. When PHP runs as FastCGI or PHP-FPM — which is the normal configuration on modern shared and cPanel hosting — PHP reads a per-directory .user.ini file that any account with file-write access can create. No root, no control-panel login, and no server configuration change is required. Write access to the web directory is enough, which is precisely what a compromised WordPress site already gives an attacker.

Two details make it worse than it first looks. PHP scans for .user.ini starting in the script’s own directory and walking up toward the document root, so a directive placed one level above the site applies to the site — and on a shared account, potentially to every site beneath that path. And the parsed values are cached, by default for five minutes, so a change you make does not take effect immediately in either direction.

The same directive can be set in php.ini and, under the classic mod_php setup, through php_value in .htaccess. If PHP runs as an Apache module on your host, .user.ini is ignored entirely and .htaccess is the file to examine instead. Check both rather than assuming which applies.

Why nothing caught it

Why Wordfence and Sucuri do not see it

A WordPress security plugin is WordPress code. It loads when WordPress loads, runs inside the WordPress request lifecycle, and is scoped to what that lifecycle can observe. A file loaded through auto_prepend_file has already executed by then. It can have written files, opened a connection, set headers, or decided what the response will be, and it is finished before the first plugin hook fires.

This is a scope boundary rather than a failure, and it is the same shape as the one on the server side: a WordPress plugin cannot see above WordPress. Here the thing above WordPress is not a hidden binary in the account home directory — it is one line of PHP configuration.

In the ClickFix incident documented on this site, this was one of the confirmed persistence layers across more than 30 WordPress and WooCommerce sites, alongside database-resident loaders and a theme-level trigger. The finding recorded at the time was that a .user.ini startup directive caused an injected component to load before normal application handling, which meant the malicious file could still load even after the WordPress-level hooks had been removed. Cleanups that addressed only the visible files did not hold.

Where to look

How to find it in the site and every parent directory

Every check here is read-only. Run them over the account, not just the WordPress root — the directive does not have to live where the site does.

  1. 01

    List every configuration file in the account

    find ~ -name '.user.ini' -o -name 'php.ini' -o -name '.htaccess' 2>/dev/null. Over SFTP or a file manager, enable hidden files and look in the web root, every directory above it up to the account home, and any directory containing a second site.

  2. 02

    Read the directives, not just the filenames

    grep -rn 'auto_prepend_file|auto_append_file' ~ 2>/dev/null finds both the prepend directive and its less-known sibling, which does the same thing after the script instead of before it. Note the exact path each one points at.

  3. 03

    Check the effective value, not only the files

    The file you are reading may not be the one in force. The safest check is your host’s PHP configuration display or WordPress’s Tools → Site Health → Info panel. Failing that, a temporary one-line script calling ini_get('auto_prepend_file'), removed immediately afterwards, shows what PHP is actually applying to web requests — which is what matters.

  4. 04

    Look at the file it points to

    Record the target’s path, size, ownership, permissions, timestamps, and SHA-256 before doing anything else. Read it in a text editor. Do not request it in a browser and do not execute it. Heavily obfuscated content, base64 blobs, or indirect function calls in a file you cannot account for answer the question.

  5. 05

    Check whether the configuration file is publicly readable

    A .user.ini that can be fetched over HTTP tells anyone who looks exactly where your payload — or your firewall — lives. Worth closing regardless of what else you find.

  6. 06

    Correlate the timestamps

    The creation time of the directive and of the file it references bounds everything else worth examining: what else was written in that window, across the account and, on a shared server, in neighbouring accounts.

Do not delete it yet

Is it malware, or is it Wordfence?

This is the step people get wrong in both directions, and it is the reason this directive causes so much confusion. Wordfence uses auto_prepend_file legitimately, by design.

Wordfence Extended Protection is the common legitimate case

Wordfence’s firewall optimisation sets auto_prepend_file to a file called wordfence-waf.php in your site root, so that the firewall loads before WordPress. Depending on your server it writes that directive into .user.ini, .htaccess, or php.ini. A .user.ini you did not create, pointing at wordfence-waf.php, is almost certainly this — and removing it silently disables your firewall’s extended mode.

Judge it by the target, not by the directive

The directive is neutral. What it points at decides everything. A path inside a plugin you installed and can name is explainable. A path to a randomly named PHP file in uploads, cache, a temp directory, or somewhere above the web root is not.

Read the target file before deciding

A legitimate prepended file is readable code belonging to identifiable software. Obfuscation, long encoded strings, or indirect execution in a file nothing claims is the answer. You do not need to decode it to make the call.

Hosting and monitoring agents also use it

Some hosts set auto_prepend_file for performance monitoring or their own security layer. If the target sits in a host-managed path outside your account, ask your host before removing it — and ask them to confirm in writing that it is theirs.

Two directives is itself a finding

Wordfence expects to own this setting. If you find a second .user.ini in a parent directory, or an auto_append_file alongside the prepend, the extra one has an explanation you have not heard yet.

Preserve either way

Copy the configuration file and the file it references, with hashes and timestamps, before changing anything. This applies just as much to something you are about to conclude is legitimate.

Removing it

The order that makes the removal hold

The sequence matters here more than usual, because of the caching behaviour and because the payload can rewrite files while it is still loaded.

  1. 01

    Preserve the evidence first

    The configuration file, the file it points to, both sets of timestamps and hashes, and a listing of the directories involved. Once the payload is gone, this is the only material that can establish what it did and how long it had been running.

  2. 02

    Remove the directive before the payload

    Take out the auto_prepend_file line — or the whole .user.ini if you created none of it — before deleting the file it references. Delete the payload first and the still-active directive can simply rewrite it on the next request, which is exactly the loop people get stuck in.

  3. 03

    Wait out the configuration cache, then verify

    Parsed .user.ini values are cached for five minutes by default, so the change is not instant. Wait, then re-check the effective value rather than assuming. If you have the access, restarting PHP-FPM applies it immediately.

  4. 04

    Now remove the payload and everything it wrote

    With the directive gone and verified gone, delete the referenced file. Then treat every file written in the same window as untrusted and restore WordPress core, plugins, and themes from official sources rather than editing them.

  5. 05

    Find how the directive got written

    Something had write access to that directory. A vulnerable plugin, stolen SFTP or control-panel credentials, a compromised neighbouring account, or a backdoor elsewhere in the account. Removing the directive without closing that path buys you days, not a fix.

  6. 06

    Rotate every credential the account holds

    Control-panel login, SFTP and FTP accounts, database users, WordPress administrators and application passwords, API tokens, and SSH keys — from a device that is not part of the incident. Assume anything readable in wp-config.php has been read.

  7. 07

    Re-check after the cache expires and traffic resumes

    Come back hours and then days later: the effective PHP configuration, the directory that held the directive, and the files that were rewritten. A prepend-based persistence that comes back is telling you the entry path is still open.

What else to check

A startup directive rarely travels alone

PHP startup persistence is one layer, and in the incidents where it shows up it is almost never the only one. The ClickFix engagement found it alongside encoded loaders in the WordPress options table and an unauthorised call in the active theme’s functions.php — three independent ways back in, any one of which would have survived a cleanup that removed the other two.

The options table

Autoloaded option values holding encoded content or loader material survive every file replacement, because they are not files. Export the rows before editing them.

The active theme

functions.php and custom include paths are a standard trigger: an unauthorised call there activates the chain on ordinary front-end requests.

mu-plugins and drop-ins

Must-use plugins load automatically and appear nowhere in the normal plugins list. object-cache.php, advanced-cache.php, and db.php are drop-ins that load early and get overlooked.

Scheduled tasks

WP-Cron events and system crontabs that rewrite files or re-fetch a payload are the other common reason deleted malware returns.

Identity persistence

Administrator accounts, application passwords, live sessions, and SSH or API credentials survive every file and configuration cleanup until they are explicitly revoked.

Other sites in the same account

A directive in a shared parent directory affects everything beneath it. Sites under one hosting account are one incident, not several.

Common questions

Common questions about auto_prepend_file malware

I have a .user.ini I did not create. Is my site hacked?

Not necessarily — and this is the most common false alarm on the topic. Wordfence writes a .user.ini containing auto_prepend_file as part of its firewall optimisation, pointing at wordfence-waf.php in your site root. Some hosts do something similar for their own monitoring. Judge it by what the directive points at, not by the fact that the file exists.

I deleted the malicious file and it reappeared in seconds. Why?

Because the thing recreating it runs before the code you are cleaning. A prepended file executes on every PHP request, so it can rewrite the payload immediately after you remove it. Remove the directive first, confirm the effective configuration has actually changed, and only then delete the file.

I removed the directive but the site still behaves the same way. What now?

Two likely reasons. Parsed .user.ini values are cached for five minutes by default, so wait and re-check the effective value rather than the file. If it has genuinely cleared and the behaviour persists, the prepend was one persistence layer among several — check the options table, the active theme, mu-plugins, drop-ins, and scheduled tasks.

My host says PHP runs as an Apache module. Does this still apply?

The mechanism does, the filename does not. Under mod_php, .user.ini is ignored and the equivalent is a php_value auto_prepend_file line in .htaccess, or a php.ini setting. Check those files instead, and check the effective configuration either way.

Wordfence scanned and found nothing. Does that clear the site?

It clears what Wordfence can see. A file loaded through auto_prepend_file has already run and finished before WordPress loads, so a plugin inside WordPress is not positioned to observe it. That is a scope boundary, not a scanner fault — and it is why this layer survives so many cleanups.

Could a directive on someone else’s site affect mine?

On the same hosting account, yes — a .user.ini in a shared parent directory applies to everything beneath it, so several sites under one account are one incident. Across separate accounts it should not, but that depends on the server actually isolating accounts properly, which on a shared or reseller server is worth verifying rather than assuming.