ORIGINALLY POSTED ON KRIOSA
This is the fourteenth article in a series on PHP and Laravel application security.
So far we have covered
- Detecting SQL injection attempts in PHP logs
- Why URL encoding blinds most PHP security checks
- The decode bomb problem with unlimited URL decoding
- Why parameterized queries are the only real fix for SQL injection
- XSS prevention in Laravel and why
{!! !!}is the line between safe and hacked - How attackers enumerate your Laravel app before exploiting it
- File upload security — the file that isn't what it claims to be
- Path traversal in PHP — how
../escapes your application - Command injection in PHP — when
exec()becomes an attack surface - Broken access control in Laravel — why being logged in is not enough
- Secrets in Laravel — why
.envis only the beginning - Session security in PHP — what most developers get wrong
- Rate limiting in Laravel and PHP — how to stop brute force before it starts
Every article in this series follows the same principle understand the attack before you try to stop it.
Most vulnerabilities in this series require changes to your business logic. Security headers are different. They require no changes to your application code. A carefully designed security-header policy applied through middleware or web server configuration hardens how the browser handles your responses while individual headers may be relevant only to certain response types.
What Security Headers Are
Every HTTP response your server sends includes headers metadata that travels alongside the content. Security headers tell the browser how to behave securely when handling your application's content.
When your server sends them the browser follows their instructions. An attacker who successfully injects a malicious script may find the browser refuses to execute it because it violates the Content Security Policy. An attacker attempting clickjacking may find the browser refuses to render your page inside their iframe. An attacker intercepting an HTTP connection may find the browser refuses to downgrade from HTTPS.
Security headers do not fix vulnerabilities in your code. They raise the cost of exploiting them.
Header 1 — Content-Security-Policy
CSP is the most powerful security header and the most complex to configure correctly.
CSP tells the browser exactly which sources of content are allowed to load on your page. Scripts, stylesheets, images, fonts, frames everything. If a source is not on the approved list the browser refuses to load it.
This directly reduces the impact of XSS attacks. Even if an attacker successfully injects a script tag, if the source is not in your CSP the browser will not execute it.
A starting point:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'
Breaking this down:
-
default-src 'self'— only load content from your own domain by default -
script-src 'self'— only execute scripts from your own domain -
style-src 'self'— only allow stylesheets from your own domain -
img-src 'self' data:— allow images from your domain and data URIs -
base-uri 'self'— prevent attackers from injecting a base tag that redirects relative URLs -
form-action 'self'— prevent forms from submitting to external domains -
object-src 'none'— block plugins entirely -
frame-ancestors 'none'— prevent your page from being embedded in iframes anywhere
Avoid 'unsafe-inline':
You will see many CSP examples that include 'unsafe-inline' for scripts or styles. Avoid it where practical — it allows inline scripts and styles to execute, significantly weakening XSS protection. Use nonces or hashes for inline scripts instead:
script-src 'self' 'nonce-{random-value}'
<script nonce="abc123def456">
// inline script here
</script>
frame-ancestors versus frame-src:
-
frame-ancestors 'none'— controls who can embed your page in an iframe (prevents clickjacking) -
frame-src 'none'— controls what iframes your page is allowed to load
Set both if your application neither embeds external content nor should be embeddable itself.
Start with report-only mode:
Content-Security-Policy-Report-Only: default-src 'self'
This logs violations without blocking anything letting you see what would break before enforcement begins. CSP is hard to get right because every third-party resource needs explicit allowance.
Header 2 — X-Frame-Options
Prevents your page from being embedded inside an iframe on another domain. Without it an attacker can overlay your application inside a transparent iframe and trick users into clicking buttons they cannot see clickjacking.
X-Frame-Options: DENY
Or to allow embedding only from your own domain:
X-Frame-Options: SAMEORIGIN
The frame-ancestors directive in CSP is the modern replacement. Keep X-Frame-Options for older browser compatibility. Note it is primarily relevant for responses rendered as documents not API responses or redirects.
Header 3 — X-Content-Type-Options
Stops browsers from guessing the content type of a response a behavior called MIME sniffing.
Without this a browser might look at a response body and decide it looks like JavaScript even if the server sent it as text/plain. An attacker who can upload a file can exploit MIME sniffing to execute scripts by making the browser misidentify the file type.
X-Content-Type-Options: nosniff
Trust the Content-Type header the server sent. Do not guess.
Header 4 — Strict-Transport-Security
HSTS tells the browser to only ever connect to your domain over HTTPS even if the user types http:// or clicks an HTTP link.
Without HSTS an attacker performing a man-in-the-middle attack can intercept the initial HTTP request before it redirects to HTTPS stealing cookies and session data before encryption begins.
Strict-Transport-Security: max-age=31536000; includeSubDomains
-
max-age=31536000— remember this rule for one year -
includeSubDomains— apply to all subdomains too
Critical: your application must already be correctly configured for HTTPS before enabling HSTS. Setting it on a domain with any HTTP content will break those pages for up to one year.
About preload: adding preload submits your domain to browser preload lists even the very first connection is forced to HTTPS before the browser has ever received an HSTS header from your server. This is difficult to reverse. Treat it as an intentional deployment decision requiring careful planning not simply another flag to copy and paste.
Header 5 — Referrer-Policy
When a user clicks a link from your application to an external site the browser sends a Referer header containing the URL they came from. This can leak sensitive information — user IDs, session tokens, or private page paths in your URLs.
Referrer-Policy: strict-origin-when-cross-origin
Sends the full URL for same-origin requests but only the origin not the full path for cross-origin requests. Sensitive URL parameters do not leak to external sites.
Other useful values:
-
no-referrer— never send any referrer information -
same-origin— only send referrer for same-origin requests -
strict-origin— only send the origin, never the full URL
Header 6 — Permissions-Policy
Controls which browser APIs your application is allowed to access. Disable features you do not need to reduce the attack surface if your application is ever compromised through another vulnerability.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Empty parentheses () disable the feature entirely. Allow what you need explicitly:
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()
Important: Permissions-Policy does not replace application authorization. If your application must prevent a user from accessing a feature the server must still enforce that. Permissions-Policy is defense in depth it reduces browser-level capability but cannot substitute for server-side access control.
Header 7 — What Happened to X-XSS-Protection?
You may still see X-XSS-Protection: 1; mode=block in older tutorials. Do not copy it blindly.
The header was designed for browser XSS filters that modern browsers have largely removed. It is deprecated, has limited support in modern browsers, and can introduce security problems. OWASP and MDN both recommend against using it as a modern XSS defense.
Your primary XSS defenses should be proper output encoding covered in article 5 and a well-designed Content Security Policy. If you inherit an application sending this header review whether it should be removed.
Implementation in Plain PHP
Add security headers early in your request lifecycle before any output is sent
function setSecurityHeaders(array $options = []): void
{
$csp = $options['csp'] ?? implode('; ', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
]);
$enableHsts = $options['hsts'] ?? false;
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
header('Content-Security-Policy: ' . $csp);
if ($enableHsts) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
HSTS is disabled by default — enable it only after confirming your entire domain is served over HTTPS.
In a bootstrap file:
<?php
require_once 'vendor/autoload.php';
setSecurityHeaders(['hsts' => true]); // only if fully HTTPS
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
At the web server level — Nginx:
server {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'" always;
}
Apache:
<IfModule mod_headers.c>
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'"
</IfModule>
Web server configuration is the most reliable approach for legacy PHP applications without a central request entry point.
Implementation in Laravel
Create the middleware:
php artisan make:middleware SecurityHeaders
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$response->headers->set(
'Content-Security-Policy',
config('security.csp', implode('; ', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
]))
);
return $response;
}
}
Register it — Laravel 11 and later:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})
Laravel 10 and earlier:
// app/Http/Kernel.php
protected $middleware = [
\App\Http\Middleware\SecurityHeaders::class,
];
Make CSP configurable:
// config/security.php
return [
'csp' => env('CSP_POLICY', implode('; ', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
])),
'hsts' => env('ENABLE_HSTS', false),
];
API routes and security headers:
Do not exclude API routes from security headers automatically. Decide which headers are meaningful for each response type. X-Frame-Options and frame-ancestors have limited relevance for JSON API responses — X-Content-Type-Options and CSP remain valuable for all response types.
Testing Your Security Headers
- securityheaders.com — shows which headers are present and what is missing
- Mozilla Observatory at observatory.mozilla.org — comprehensive security scan
- Browser DevTools — Network tab, select any request, Headers section
A high scanner score does not prove your application is secure. Headers cannot replace secure authentication, authorization, input validation, output encoding, dependency management, or server configuration. Use scanner scores as a signal — not as evidence of security.
The Security Headers Checklist
For plain PHP:
- Call your security headers function before any output
- Use a front controller or web server config to ensure every response gets headers
- Start CSP in report-only mode before enforcing
- Enable HSTS only after confirming all subdomains are HTTPS
- Avoid
'unsafe-inline'in CSP — use nonces or hashes for inline scripts - Do not add
X-XSS-Protection— it is deprecated - Test with securityheaders.com as a baseline check after deployment
For Laravel:
- Create a global middleware that runs on every request
- Register it in the global middleware stack
- Make CSP configurable through environment variables
- Decide per response type which headers are relevant — do not exclude API routes automatically
- Enable HSTS only in production where all subdomains are HTTPS
- Avoid
'unsafe-inline'in CSP - Do not add
X-XSS-Protection - Use scanner scores as signals not as proof of security
Where Kriosa Fits
Security headers protect the browser after a response is generated. They are a browser-enforcement layer.
Kriosa operates at a different layer entirely.
Kriosa analyzes incoming requests for suspicious reconnaissance and attack patterns — automated scanning, repeated probing, malicious payloads, and other behavioral signals that appear before or alongside an attempted exploit. These request patterns are surfaced in the XAI dashboard with an explanation of what was detected and why it was flagged.
The two controls solve different problems. Security headers harden how the browser handles your responses. Kriosa monitors what is happening at the request layer before a response is ever generated.
Production PHP applications are already running with Kriosa in front of them.
Prolify — a design-proofing platform where designers share work with clients for review and approval. Every session is a trust boundary. A compromised login means a client’s unreleased creative work is exposed to the wrong person.
belle-full — a PHP application built for bakers, handling real customer orders and business data. Secured with Kriosa from day one not retrofitted after a scare, built with protection as a foundation.
Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php
Documentation: kriosa Docs.
Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.
The Series So Far
- Article 1 : What your PHP logs actually look like during a SQL injection attack
- Article 2 : Why URL encoding can break PHP security checks
- Article 3 : The decode bomb problem — why unlimited URL decoding can be its own vulnerability
- Article 4 : Parameterized queries — the only real fix for SQL injection
- Article 5: XSS prevention in Laravel and why
{!! !!}is the line between safe and hacked - Article 6: How attackers enumerate your Laravel app before exploiting it
- Article 7: File upload security in PHP and Laravel
-
Article 8 : Path traversal in PHP — how
../escapes your application -
Article 9 : Command injection in PHP — when
exec()becomes an attack surface - Article 10: Broken access control in Laravel — why being logged in is not enough
-
Article 11: Secrets in Laravel — why
.envis only the beginning - Article 12: Session security in PHP — what most developers get wrong
- Article 13 : Rate limiting in Laravel and PHP — how to stop brute force before it starts
- Article 14: This article — security headers in PHP and Laravel
Top comments (0)