Part of the guide: Website Security on Shared Hosting: Practical Guides
Security Headers in .htaccess on cPanel: Fix Guide (2026)
"Missing security headers" is a common warning in website security scans, even when your cPanel site already has a valid SSL certificate. It means your server isn't sending the HTTP response headers that tell browsers how to handle framing, scripts, HTTPS and sensitive browser features.
If you're fixing security headers in .htaccess on cPanel, you can add most of these protections without another WordPress plugin and without touching your Laravel code.
Quick answer: Open your site's .htaccess file in cPanel File Manager and add an Apache mod_headers block for X-Content-Type-Options, Referrer-Policy, X-Frame-Options, a frame-ancestors Content-Security-Policy and Permissions-Policy. Enable HSTS separately, starting with a five-minute lifetime. Test the homepage, an error page, a static file and the browser console before enforcing a stricter CSP.
This guide uses example.com throughout. Replace it with your domain.
Step 1: Know which security headers matter in 2026
A scanner might recommend a dozen headers. Not all of them suit every site, and some older recommendations are obsolete. These are the six I'd check first:
| Header | What it protects |
|---|---|
| Strict-Transport-Security | Tells browsers to keep using HTTPS |
| Content-Security-Policy | Restricts which content may load and who may embed the page |
| X-Frame-Options | Clickjacking protection for browsers that don't use CSP frame-ancestors |
| X-Content-Type-Options | Stops MIME sniffing for certain resources |
| Referrer-Policy | Controls what goes into the Referer header |
| Permissions-Policy | Restricts access to selected browser features |
HSTS and CSP need more planning than the rest. HSTS can make browsers refuse plain HTTP for a long time, and a careless CSP can block scripts, styles, embeds and third-party services.
Two outdated headers to remove
You'll find this in older .htaccess tutorials:
Header always set X-XSS-Protection "1; mode=block"
Don't copy it. X-XSS-Protection is deprecated; modern browsers rely on other protections, including CSP, and some of the old XSS filters could introduce problems of their own. OWASP recommends leaving the header out, or sending X-XSS-Protection: 0 to explicitly disable legacy filtering.
The other obsolete line is:
Header always set X-Frame-Options "ALLOW-FROM https://partner.example"
Modern browsers don't support ALLOW-FROM. If a trusted partner must embed your site, use CSP's frame-ancestors with that origin instead. The OWASP Secure Headers Project keeps current recommendations.
Do security headers improve SEO?
Google has named HTTPS as a ranking signal, but there's no established direct ranking boost for any particular security header or for an A grade on a scanner. Headers still matter: they reduce browser-side risk for your users. Treat the scanner score as a diagnostic, not a Google score.
Step 2: Add security headers in .htaccess on cPanel
Back up the existing .htaccess first. In cPanel, open File Manager → public_html and enable Show Hidden Files (dotfiles) if .htaccess isn't visible.
For a typical WordPress install, the file is:
/home/username/public_html/.htaccess
For a Laravel app whose document root is its public folder, it's:
/home/username/myapp/public/.htaccess
Edge case: some Laravel deployments point the domain at the project root and use a root .htaccess that rewrites everything into public/. In that setup, requests pass through both files. Put the headers in one of them, not both, or you'll chase duplicates later.
Edit the .htaccess that actually sits in your site's document root. A file in an unrelated folder does nothing.
Copy this starter configuration
Place this block outside WordPress's # BEGIN WordPress / # END WordPress section (WordPress may rewrite that section) and outside Laravel's rewrite rules:
# BEGIN Custom Security Headers
<IfModule mod_headers.c>
# Prevent MIME sniffing
Header onsuccess unset X-Content-Type-Options
Header always set X-Content-Type-Options "nosniff"
# Limit referrer information
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Allow framing only by the same origin (older browsers)
Header onsuccess unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
# Allow framing only by the same origin (modern browsers)
Header onsuccess unset Content-Security-Policy
Header always set Content-Security-Policy "frame-ancestors 'self';"
# Disable selected browser features
Header onsuccess unset Permissions-Policy
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
# Remove the PHP version disclosure header
Header onsuccess unset X-Powered-By
Header always unset X-Powered-By
</IfModule>
# END Custom Security Headers
This is a conservative start for sites that don't need to be embedded elsewhere and don't use the camera, microphone or geolocation. It deliberately leaves HSTS out; that comes in Step 3.
Two things to check before you save:
- Existing CSP. These lines replace any CSP that Apache, PHP or a plugin already sends on these responses. If your site has a policy, merge
frame-ancestorsinto it instead of overwriting it. - Permissions-Policy. If your app needs one of those features (a store locator using geolocation, say), remove that directive.
Keep X-Frame-Options and frame-ancestors saying the same thing. When both are present, modern browsers follow frame-ancestors and ignore X-Frame-Options, so a mismatch means old and new browsers behave differently.
If you'd rather not hand-write rules, the HeadsNTail .htaccess generator can produce a starting file to compare against.
Why Header always and not Header set
Apache keeps two response-header tables. A plain Header set writes to the default onsuccess table. The always table is the one used for redirects and locally generated errors, and it's also where headers from CGI scripts and from PHP-FPM (via mod_proxy_fcgi) end up.
That's why each header gets two lines:
Header onsuccess unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
The first clears any copy in the default table; the second sets your value in the always table, replacing what PHP sent. Without the first line, a header set in both tables shows up twice. The same logic explains Header always unset X-Powered-By: PHP-FPM's X-Powered-By lives in the always table, so an onsuccess unset alone wouldn't remove it.
The details are in Apache's mod_headers documentation.
If the block has no effect
Apache needs mod_headers loaded, and the server config must allow Header in .htaccess (AllowOverride FileInfo or broader). On shared cPanel hosting, your provider controls both.
The <IfModule mod_headers.c> wrapper decides how failure looks. Without it, a missing module turns the Header lines into a 500 error. With it, Apache silently skips the block. So if none of your headers appear, ask the host whether mod_headers and .htaccess overrides are enabled.
Step 3: Enable HSTS without locking yourself out
HTTP Strict Transport Security tells browsers to use HTTPS for every future request to the hostname. A normal HTTP-to-HTTPS redirect still lets that first HTTP request happen; HSTS removes it once the browser has seen the policy. It also stops users from clicking through certificate errors, which is good protection and a real risk if your HTTPS isn't solid.
Start with five minutes
First confirm both of these load over HTTPS with valid certificates and the redirects you expect:
https://example.com
https://www.example.com
Then add this below the starter block:
<IfModule mod_headers.c>
Header onsuccess unset Strict-Transport-Security
Header always set Strict-Transport-Security "max-age=300" "expr=%{HTTPS} == 'on'"
</IfModule>
max-age=300 is five minutes. The expr condition sends the header only on requests Apache sees as HTTPS (this syntax needs Apache 2.4.10 or later, which current cPanel EasyApache 4 servers run).
Behind Cloudflare, check your SSL mode. In Flexible mode, Cloudflare talks to your server over plain HTTP, Apache never sees HTTPS == on, and the header never goes out; set HSTS in Cloudflare's own settings instead. In Full or Full (strict) mode, the origin connection is HTTPS and the Apache rule works. Cloudflare adds its own surprises in front of an app, as covered in Cloudflare's JS challenge breaking a Laravel SPA.
After saving, click through the homepage, login, forms, admin pages and important redirects. Look for certificate warnings and mixed-content (HTTP-only) resources.
Increase the lifetime after testing
Once HTTPS has been reliable for a while:
<IfModule mod_headers.c>
Header onsuccess unset Strict-Transport-Security
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
</IfModule>
31536000 seconds is one year. Each visit refreshes the timer. Don't make a one-year policy your first production test.
Be careful with includeSubDomains
Strict-Transport-Security: max-age=31536000; includeSubDomains
includeSubDomains applies the policy to every subdomain of the host that sends it. Published on example.com, it covers:
mail.example.com
old.example.com
staging.example.com
Every one of those then needs working HTTPS for anyone whose browser has the policy. Check all of them first, including forgotten staging sites and internal tools.
Why HSTS preload needs extra caution
Preloading puts your domain into browsers' built-in HTTPS-only lists, so even the first visit is protected. A typical preload-ready header looks like:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Adding preload doesn't enroll you. The domain has to meet the requirements at hstspreload.org (valid HTTPS, the right redirects, a long enough max-age, includeSubDomains) and be submitted there. Getting removed again takes months to reach users. I'd skip preload on an ordinary shared-hosting site unless someone owns HTTPS across the whole domain long term.
If browsers are stuck on HTTPS
Say you set a long HSTS policy and later drop the certificate. Switching the site back to HTTP won't help visitors whose browsers cached the policy; they'll keep forcing HTTPS. The real fix is to restore working HTTPS.
To retire a non-preloaded policy, serve this over valid HTTPS:
Strict-Transport-Security: max-age=0
Browsers that receive it drop the cached policy. For your own Chrome while debugging, chrome://net-internals/#hsts has historically let you inspect and delete entries, though that page can change between versions. Clearing your browser doesn't fix anyone else's.
Step 4: Add CSP without breaking Laravel or WordPress
CSP is one of the most useful browser controls and the easiest to get wrong. A WordPress site might depend on Google Analytics 4, Google Tag Manager, AdSense, Google Fonts, inline JavaScript, payment widgets and embedded videos. Laravel apps may use inline scripts, Vite-built assets, Livewire or third-party packages.
So don't paste this into production as a first step:
Content-Security-Policy: default-src 'self'
It will likely block external resources and inline scripts your site needs.
Start with frame-ancestors
The starter block already sends:
Header always set Content-Security-Policy "frame-ancestors 'self';"
'self' allows same-origin framing. If the site should never be framed at all:
Header always set Content-Security-Policy "frame-ancestors 'none';"
If you switch to 'none', change X-Frame-Options to DENY to match.
Don't confuse the two framing directives: frame-ancestors controls who may embed your page, frame-src controls what your page may embed. And frame-ancestors only works as an HTTP header, not in a <meta> CSP tag. See MDN's frame-ancestors reference.
Test resource rules with Report-Only
Before enforcing script and resource restrictions, run them in report-only mode:
<IfModule mod_headers.c>
Header onsuccess unset Content-Security-Policy-Report-Only
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self';"
</IfModule>
This is deliberately strict. Browsers evaluate it and log violations in the developer console without blocking anything. Browse the main pages with the console open and note every resource that "would be blocked": that's your to-do list.
This example has no reporting endpoint, so violations only appear in your own console. For real-visitor data, add a reporting endpoint with the report-to (or legacy report-uri) directive. The enforced frame-ancestors policy from Step 2 stays active alongside it.
Build the policy from what the site actually loads
If your scripts come from your own origin and Google Tag Manager, a starting point is:
script-src 'self' https://www.googletagmanager.com
That alone usually won't cover Google Analytics or AdSense; those load from more domains and make extra network requests. Add each origin to the directive the console says it needs. Don't paste in broad wildcards just to silence warnings.
For inline scripts, prefer nonces or hashes over 'unsafe-inline'. Laravel can generate a per-request nonce in middleware and print it into your script tags. WordPress is harder, because themes and plugins inject scripts you don't control, so audit, run Report-Only for a while, then enforce gradually.
Step 5: Test the headers and fix duplicates, gaps and 500 errors
A saved .htaccess doesn't prove the headers are going out. Check real responses.
Test with curl
curl -I https://example.com
Filter for just the security headers:
curl -sSI https://example.com | grep -iE 'strict-transport-security|content-security-policy|x-frame-options|x-content-type-options|referrer-policy|permissions-policy|x-powered-by'
On Windows PowerShell, call curl.exe explicitly, because in Windows PowerShell 5.1 curl is an alias for Invoke-WebRequest:
curl.exe -I https://example.com
Test more than the homepage:
curl -I https://example.com/
curl -I https://example.com/login
curl -I https://example.com/this-page-does-not-exist
curl -I https://example.com/favicon.ico
curl -I http://example.com/
That covers a normal page, an app route, a 404, a static file and the plain-HTTP redirect. curl -I sends a HEAD request; if your app treats HEAD differently, repeat with a GET and print headers:
curl -s -D - -o /dev/null https://example.com/
Comparing these responses also tells you where a header comes from. Present on pages but missing on the static file usually means something other than Apache served that file. Present on 200s but missing on 404s usually means the error page came from another layer.
Headers missing on 404 pages or static files
Header always set covers Apache's own error responses. But a CDN, a cache, or a reverse proxy can generate responses that never touch your .htaccess. Some cPanel servers put NGINX in front of Apache (cPanel's NGINX reverse proxy, or Engintron); depending on the setup, cached or static responses may come straight from NGINX. If only certain responses lack the headers, find out which layer produced them and ask the host where headers should be set.
Duplicate Content-Security-Policy headers
If Apache, Laravel middleware, Cloudflare or a WordPress plugin each send a CSP, the browser enforces all of them together. It doesn't pick the most permissive one, so a resource allowed by one policy is still blocked if another forbids it.
Keep one owner for CSP. If another layer already owns it, remove the .htaccess CSP line or merge your directive there. Don't unset a stronger existing policy without reading what it protects.
X-Powered-By keeps appearing
The starter block removes it from both Apache tables. If it still shows, another proxy or the application is adding it after Apache.
The root setting is expose_php = Off, but it's a system-level PHP directive (PHP_INI_SYSTEM): it can't be changed from .htaccess or .user.ini, only from the main php.ini. On shared cPanel hosting, whether you can change it depends on what your host exposes in MultiPHP INI Editor or PHP Selector. If it isn't available there, the Header always unset X-Powered-By line is your practical fix. Either way, hiding the header doesn't patch anything; keep PHP, Laravel, WordPress and plugins updated.
HSTS appears on HTTP responses
curl -I http://example.com
You should get a redirect to HTTPS. If the HTTP response also carries Strict-Transport-Security, the condition isn't working. Browsers ignore HSTS sent over plain HTTP, so it does no harm, but it tells you the rule isn't behaving as written; fix the condition rather than ignoring it.
A 500 error right after editing .htaccess
Restore the backup first. Then look for typos, unbalanced quotes, or directives outside the <IfModule> wrapper. The cPanel Errors page or the domain's error log usually names the bad line. If a correctly wrapped block simply has no effect, go back to "If the block has no effect" in Step 2.
Run an external scan
Use SecurityHeaders.com or Mozilla Observatory for a second view. Their scoring differs, and there's no universal formula for an A. Some also look at headers like Cross-Origin-Opener-Policy or Cross-Origin-Resource-Policy. Don't add cross-origin isolation headers just to raise a grade; they can break popups, embeds, logins and payment flows. A working, reasonably strict setup beats a perfect grade on a broken checkout.
Step 6: Choose the right place in Laravel and WordPress
Laravel: .htaccess or middleware?
On Apache, .htaccess headers apply to static files and to requests routed into Laravel. Middleware only runs for requests Laravel handles, so it never touches your CSS, images or Apache's own error pages.
For a typical Laravel app on cPanel, I'd set the simple site-wide headers in .htaccess and handle CSP in middleware when you need per-request nonces or route-specific rules. Just don't send an enforced CSP from both places.
WordPress: watch plugins and admin pages
Plugins often add scripts or frames that need special handling. After changing CSP or X-Frame-Options, test:
- The dashboard and the block editor
- Login and password reset
- Page builders
- Payment and checkout pages
- Embedded media
SAMEORIGIN lets your own pages frame each other and blocks other sites. If a trusted external service genuinely needs to embed one page, write a specific frame-ancestors rule for it rather than switching clickjacking protection off for the whole site.
FAQ
Do security headers improve SEO rankings?
They aren't established direct Google ranking factors. HTTPS is a known ranking signal, but an A grade from a header scanner doesn't promise better rankings. Set headers to protect users and reduce browser-side attacks.
Is X-XSS-Protection still needed in 2026?
No. It's deprecated and isn't a modern XSS defence. Remove old X-XSS-Protection: 1; mode=block lines and rely on a proper CSP; OWASP suggests omitting the header or setting it to 0.
Can I set security headers in PHP instead of .htaccess?
Yes. PHP and Laravel middleware can send headers for every request the application handles. They won't cover static files or Apache's own error pages, so .htaccess is better for site-wide defaults and middleware for dynamic CSP nonces or per-route rules.
Will enabling HSTS break my website?
It can, if HTTPS isn't fully working or if includeSubDomains covers a subdomain that still uses plain HTTP. Start with max-age=300, check certificates and redirects, then raise the lifetime in steps. Be especially careful with preload, because removal takes months.
Once your security headers in .htaccess on cPanel are live, re-test HTTPS, CSP console warnings, admin pages, a 404 and a static file before you extend HSTS or enforce stricter script rules. If you want a second pair of eyes on a production setup, contact HeadsNTail.


