Every PHP developer has a .htaccess file they copied from somewhere. It has RewriteEngine On at the top, a couple of RewriteRule lines they're afraid to touch, and maybe some security headers they saw in a blog post.
It works. They don't know why. They move on.
Let's fix that.
What .htaccess is
.htaccess is a per-directory configuration file for Apache. When Apache handles a request, it walks the directory tree from the root to the requested file, reading every .htaccess file it finds along the way. Each one can add or override configuration for that directory and everything below it.
This is important: .htaccess isn't a separate system. It's Apache's main configuration, just loaded from a different place. The directives are identical to what you'd put in httpd.conf. The only difference is scope and when they're read.
httpd.conf is read once at startup. .htaccess is read on every request. That's the tradeoff — convenience and flexibility at the cost of performance. For most sites, the cost is negligible. For high-traffic sites, moving rules to httpd.conf is a meaningful optimization.
RewriteEngine and RewriteRule
The rewrite module (mod_rewrite) is the most common reason people touch .htaccess. Here's how it actually works:
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
Line by line:
RewriteEngine On — Enables the rewrite engine for this directory. Without this, all RewriteRule and RewriteCond directives are ignored.
RewriteBase / — Sets the base URL for relative rewrites. When Apache internally rewrites a URL, it needs to know where the directory maps to in URL space. For most sites in the web root, this is /.
RewriteCond %{REQUEST_FILENAME} !-f — A condition that must be true for the next RewriteRule to apply. %{REQUEST_FILENAME} is the full filesystem path Apache mapped the URL to. !-f means "is NOT a regular file." So this condition passes only when the URL doesn't point to an actual file.
RewriteCond %{REQUEST_FILENAME} !-d — Same thing, but for directories. !-d means "is NOT a directory."
RewriteRule ^ index.php [L] — The actual rewrite. The pattern ^ matches any URL (it just means "start of string"). The substitution is index.php. The [L] flag means "last rule" — stop processing rewrite rules.
Together: if the request isn't for a real file or directory, send it to index.php. This is the front controller pattern. Every PHP framework uses it.
Flags matter
RewriteRule flags change behavior significantly:
[L]— Last rule. Stop processing.[R=301]— External redirect (the browser sees a new URL). 301 means permanent.[F]— Forbidden. Return a 403 error.[QSA]— Query String Append. Keep the original query string when rewriting.[NC]— No Case. Case-insensitive pattern matching.
A common mistake: forgetting that without [L], Apache continues evaluating rules after a match. Your carefully crafted rewrite gets overridden by the next rule. If your rewrites aren't working, check your flags first.
Security headers
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set Content-Security-Policy "default-src 'self'"
These use mod_headers to add HTTP response headers. They don't change what Apache serves — they tell the browser how to handle the response.
X-Content-Type-Options: nosniff — Prevents the browser from guessing content types. If you serve a file as text/plain, the browser won't try to render it as HTML even if it looks like HTML. This blocks a whole class of attacks.
X-Frame-Options: SAMEORIGIN — Prevents your page from being loaded in an iframe on another domain. Blocks clickjacking.
Content-Security-Policy — The big one. Controls what resources the page can load. default-src 'self' means "only load resources from this domain." You can then add exceptions: script-src 'self' https://cdn.example.com allows scripts from your domain and that specific CDN.
CSP is powerful but strict. A restrictive CSP will break inline scripts and styles. That's the point — if you can't use inline scripts, neither can an attacker who finds an injection point.
Access control
<FilesMatch "\.(md|db|sqlite|log)$">
Require all denied
</FilesMatch>
<FilesMatch> takes a regular expression and applies directives to any file that matches. Require all denied blocks all access. This is how you keep config files, databases, and logs from being served to the internet.
You can also block directories:
RewriteRule ^includes/ - [F,L]
This matches any URL starting with includes/ and returns 403. The - means "don't rewrite" — just apply the flags.
The performance question
Every .htaccess file is parsed on every request. If your site has five directories deep, Apache reads up to five .htaccess files per request.
For most sites, this is fine. The files are tiny and the filesystem cache keeps them hot. But if you control the server config, you can move everything to httpd.conf and disable .htaccess entirely with AllowOverride None. Apache won't even look for the files, saving a few stat() calls per request.
On shared hosting, you don't have that option. .htaccess is your only interface to Apache configuration. It's not ideal, but it works.
What I actually use
Here's the .htaccess running this site, stripped to its essentials:
- HTTPS redirect — Behind Cloudflare, so it checks
X-Forwarded-Proto - Front controller — Everything that isn't a real file goes to
index.php - File blocking —
.md,.db,.sqlite,.logfiles return 403 - Directory blocking —
includes/,content/,pages/return 403 - Security headers — CSP, X-Frame-Options, nosniff, referrer policy
- Compression —
mod_deflatefor text content - Cache headers —
mod_expiresfor static assets
Twelve directives doing meaningful security and performance work. No magic. No copy-paste cargo cult. Just Apache doing what you told it to do.
Comments
Loading comments...