← Back to blog

The WordPress .htaccess file: what core actually writes, and when it silently does not

The SandyWP team 6 min read

WordPress owns exactly one block of your .htaccess file: the lines between # BEGIN WordPress and # END WordPress. Everything above and below that block is yours, is never touched, and survives every permalink change.

That is the first thing most guides get wrong, and the rest of this post is the other things. All the core behaviour below was read from WordPress/wordpress-develop trunk on 2026-09-29.

What is in the default WordPress .htaccess file?

This is what WP_Rewrite::mod_rewrite_rules() generates, in src/wp-includes/class-wp-rewrite.php:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

Four lines do the real work. RewriteBase is the path part of your home_url(), so a site in a subdirectory gets RewriteBase /blog/ rather than /.

The two RewriteCond lines are the front controller: if the request does not match a real file (!-f) or a real directory (!-d), hand it to index.php. That is why a physical file in your web root always wins over a WordPress page with the same slug.

The line most "default .htaccess" guides are missing

RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] copies the incoming Authorization header into an environment variable PHP can read.

It is there because Apache running PHP through CGI or FPM strips that header before PHP sees it. Without this line, Application Passwords and any other Basic Auth against the REST API fail with a 401, on a site whose .htaccess otherwise looks perfectly normal.

It was added to core in the same commit that introduced Application Passwords: 1856d0fe, 2020-10-08, in the WordPress 5.6 cycle. So any .htaccess block copied from a guide written before late 2020 is missing it.

That is not hypothetical. Hostinger's default .htaccess tutorial (fetched 2026-09-29) publishes the pre-5.6 block and says the code "is universal for all WordPress sites".

Kinsta's guide (also fetched 2026-09-29, ~1,700 words) shows its default block only as a screenshot and does not mention the header at all.

If you paste either one in and REST authentication breaks a week later, nothing in your site connects the two events.

Do my custom rules get overwritten?

No, as long as they are outside the markers. insert_with_markers() in src/wp-admin/includes/misc.php reads the whole file into three buckets: lines before # BEGIN WordPress, lines between the markers, and lines after # END WordPress.

It then rewrites the file as pre_lines + start marker + new rules + end marker + post_lines. Your Deny from, your HSTS header, your caching plugin's own marked block: all preserved verbatim.

Rules you put inside the WordPress markers are a different story. They are in the bucket core replaces, so they vanish the next time rules are flushed, and core writes a translated comment inside the block saying exactly that.

Put custom Apache directives above # BEGIN WordPress, or add them to the generated block through the mod_rewrite_rules filter if a plugin needs to own them.

When WordPress writes the file, and when it does not

Only one function writes the block: save_mod_rewrite_rules(). It is called from WP_Rewrite::flush_rules() on a hard flush, which is what the Permalinks screen and flush_rewrite_rules() trigger.

Four conditions in that path each stop the write on their own.

It returns null immediately on multisite. save_mod_rewrite_rules() opens with if ( is_multisite() ) { return null; }. On a network, core never writes your .htaccess after install, which is why every multisite guide hands you a block to paste by hand and why subdirectory and subdomain networks need different blocks.

The server has to look like Apache, by string match. got_mod_rewrite() calls apache_mod_loaded( 'mod_rewrite', true ), which returns false straight away unless the global $is_apache is set. That global is one line in src/wp-includes/vars.php:

$is_apache = ( str_contains( $_SERVER['SERVER_SOFTWARE'], 'Apache' )
	|| str_contains( $_SERVER['SERVER_SOFTWARE'], 'LiteSpeed' ) );

So the decision is a substring test on whatever the SAPI reports. Apache behind an nginx proxy that rewrites SERVER_SOFTWARE, or a container image with an unusual server token, means core writes nothing while Apache is still reading .htaccess for real.

And when it cannot tell, it assumes yes. The true in apache_mod_loaded( 'mod_rewrite', true ) is the default the function returns when it cannot introspect.

Under PHP-FPM apache_get_modules() does not exist, and phpinfo() is often in disable_functions, so both checks fall through and core writes the block on faith. Usually correct, and harmless either way because of the <IfModule> wrapper.

The file has to be writable, and a failed write is thrown away. insert_with_markers() returns false if the file exists and is not writable, or if the file is missing and the directory is not writable. flush_rules() calls save_mod_rewrite_rules() and ignores the return value entirely:

if ( function_exists( 'save_mod_rewrite_rules' ) ) {
	save_mod_rewrite_rules();
}

The Permalinks screen checks writability itself and shows you an error with the rules to paste. A plugin calling flush_rewrite_rules() on activation gets no such treatment: the call succeeds, the file is unchanged, and nothing is logged.

Note the function_exists() guard too. save_mod_rewrite_rules() lives in wp-admin/includes/misc.php, so on a request where the admin includes are not loaded, a hard flush does not even attempt the file.

Permalinks are the symptom people search for, and the cause is almost always one of two things rather than a broken rule.

AllowOverride is the first. Apache ignores .htaccess completely unless the <Directory> block for your web root permits it, and AllowOverride None has been the Apache default since 2.3.9.

Nothing in WordPress can detect this: core writes a correct file that the server declines to read.

RewriteBase is the second. It is baked in from home_url() at write time, so moving a site from example.com/dev/ to example.com/ leaves RewriteBase /dev/ in place.

Saving the Permalinks screen once regenerates it, which is the real reason that advice works.

If your host runs nginx, none of this applies. got_url_rewrite() returns true for nginx, Caddy and FrankenPHP so WordPress stops warning you about permalinks, but no file is written because nginx does not read one.

Rewrites live in the server config instead.

Editing it without breaking the site you are editing

A wrong directive in .htaccess is not a PHP error. Apache returns 500 for every request in that directory tree, including /wp-admin, so you cannot log in to undo it.

There is no Recovery Mode for this, because WordPress never gets to run.

That makes .htaccess work a good fit for a throwaway copy. On a SandyWP sandbox you edit the file over SSH or SFTP, break it, read the Apache error, and fix it without a live site being down while you learn what the directive does.

Two habits worth keeping either way:

Keep a known-good copy before you touch it. cp .htaccess .htaccess.bak costs nothing and is the difference between a ten-second fix and an FTP client you have not opened in a year.

Test the write path, not just the rules. If you are shipping a plugin that calls flush_rewrite_rules(), activate it once on a sandbox with a read-only .htaccess and confirm your plugin still works.

Core will not tell you the write failed, so your code has to cope with the block being stale. A disposable install is the cheap place to find that out.

The short version

WordPress manages one marked block, not the file. It decides whether to write it from a substring match on SERVER_SOFTWARE, assumes mod_rewrite is present when it cannot check, skips the write entirely on multisite, and discards the failure when the file is read-only.

Your own rules are safe as long as they sit outside the markers. And if you are copying a default block from a guide, check it has the HTTP_AUTHORIZATION line, because the ones that predate WordPress 5.6 do not.