HNT-START
TITLE: Mitigate BREACH Without Saying Goodbye to Compression
SLUG: mitigate-breach-attacks-keep-compression
EXCERPT: How BREACH abuses gzip and Brotli on HTTPS, and how to protect Laravel apps with token masking, SameSite and selective compression, not gzip off.
CATEGORY: Security
The most common mistake is turning off gzip for the whole site because a scanner like testssl.sh flagged BREACH (CVE-2013-3587). Your CSS, JavaScript and fonts get bigger, every page gets slower, and the static files you just stopped compressing were never at risk. You can mitigate BREACH attacks without saying goodbye to compression, but you need to know which responses are actually exposed.
BREACH only works on a response that is compressed, reflects attacker-controlled input, and contains a secret, while the attacker can trigger many requests and watch their sizes. Break any one of those conditions per response. In practice: mask CSRF tokens on every request, use SameSite cookies, and skip compression only for cross-site requests.
What a BREACH attack on gzip actually needs
Here's the running example for this whole article. A Laravel app has a /search?q= page that prints "Results for: {q}". The shared layout includes a logout form with @csrf, so every page contains the user's CSRF token in a hidden field. Nginx gzips the HTML over HTTPS.
DEFLATE (the algorithm behind gzip) replaces repeated strings with short back-references. If the attacker sets q to name="_token" value="a, and the real token starts with a, that string repeats in the page and the compressed response gets slightly shorter. A wrong guess doesn't repeat, so the response stays slightly longer.
Encryption hides the content but not the length. So an attacker who can:
- make the victim's browser send requests to
/search?q=...(from a malicious page with images orfetch()calls), - observe the encrypted response sizes on the network (same Wi-Fi, a compromised router, an ISP-level position),
can guess the token one character at a time. Brotli has the same weakness, since it also finds repeated strings.
Why the attacker needs a known prefix
The guess only works because the attacker knows the text right before the secret. In our example that's name="_token" value=", and anyone can see it by creating their own account and viewing the page source. The attacker glues one guessed character onto that prefix, and a match produces a longer repeated string than a miss.
This is also why masking works so well. If the bytes after value=" change on every response, there's no fixed secret to extend the match into. The prefix still matches, but nothing after it does.
Watching the compression oracle in PHP
You can see the length difference locally without any network:
<?php
$page = fn (string $q) =>
'<form><input type="hidden" name="_token" value="7f3a9cd21e"></form>'
. '<p>Results for: ' . $q . '</p>';
foreach (['name="_token" value="7f3a9cd21z', 'name="_token" value="7f3a9cd21e'] as $guess) {
printf("%-40s %d bytes\n", $guess, strlen(gzencode($page($guess), 6)));
}
Run it with php oracle.php. The correct guess usually comes out the same size or a little smaller. It's not a clean one-byte signal every time because Huffman coding adds noise, which is why the real attack sends many requests per character and uses padding tricks. The direction of the leak is still there.
Why disabling TLS compression or using HTTP/2 doesn't stop BREACH
This confuses a lot of people. CRIME attacked TLS-level compression, and turning that off (which modern servers already do) fixed it. BREACH attacks HTTP body compression, which sits above TLS. Your TLS settings don't touch it.
HTTP/2 doesn't help either. It changes how headers are compressed (HPACK was designed with CRIME in mind), but response bodies are still gzip or Brotli when the client asks for them.
Cookies themselves aren't the target. They travel in headers, which HTTP/1.1 never gzips. The target is any secret printed into the HTML body.
Finding the BREACH-exposed pages in your own app
Before changing anything, work out where the two ingredients meet. You're looking for responses that contain a secret and also echo something from the request.
Secrets in a typical Laravel app's HTML:
- the
_tokenhidden field from@csrfand the<meta name="csrf-token">tag - values passed to JavaScript with
@json, such as API keys or user IDs - personal data on account pages (email, phone, address)
- initial page data embedded by front-end stacks, like Inertia's
data-pageattribute or Livewire component state
Reflected input usually comes from search pages, filter forms that repeat their values, pagination links that carry the query string, and error pages that print the requested URL. A quick way to find candidates in your views:
grep -rn "request(\|request()->\|\$request->query\|old(" resources/views
Then list the GET routes, since cross-site attacks mostly use GET:
php artisan route:list --method=GET
Any GET route that renders a view with both ingredients is in scope. In our example that's /search, but also every page using the shared layout that keeps ?page= or ?sort= in its links.
How to mitigate BREACH attacks without saying goodbye to compression
You don't need every condition broken everywhere. You need at least one broken on every response that has both a secret and reflected input. Here are the options, roughly in the order I'd apply them.
Mask the CSRF token on every response
This is the standard fix, and frameworks like Django and Rails do it out of the box. Instead of printing the raw token, print random_pad + (random_pad XOR token). The value changes on every response, so there's no stable string for the attacker to match against. The server reverses the XOR before comparing.
Laravel prints the raw session token with @csrf and csrf_token(), so you add masking yourself. First the helper, in app/Support/MaskedCsrf.php:
<?php
namespace App\Support;
class MaskedCsrf
{
public static function token(): string
{
$token = csrf_token();
$pad = random_bytes(strlen($token));
return 'm.' . bin2hex($pad . ($pad ^ $token));
}
public static function unmask(string $value): ?string
{
if (! str_starts_with($value, 'm.')) {
return $value;
}
$hex = substr($value, 2);
if ($hex === '' || strlen($hex) % 4 !== 0 || ! ctype_xdigit($hex)) {
return null;
}
$raw = hex2bin($hex);
$half = intdiv(strlen($raw), 2);
return substr($raw, 0, $half) ^ substr($raw, $half);
}
}
The m. prefix matters. Without it, you can't tell a masked token from a plain one, and Laravel's own X-XSRF-TOKEN header path (which axios uses) would break.
Next, a middleware that unmasks before Laravel compares, in app/Http/Middleware/MaskedCsrfToken.php:
<?php
namespace App\Http\Middleware;
use App\Support\MaskedCsrf;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
class MaskedCsrfToken extends ValidateCsrfToken
{
protected function getTokenFromRequest($request)
{
$token = parent::getTokenFromRequest($request);
return is_string($token) ? MaskedCsrf::unmask($token) : $token;
}
}
The parent's comparison already rejects anything that isn't a string, so a malformed masked token (where unmask() returns null) fails validation with the usual 419 response instead of throwing an error.
Swap it into the web group in bootstrap/app.php (Laravel 11 and later):
->withMiddleware(function (Middleware $middleware) {
$middleware->web(replace: [
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class
=> \App\Http\Middleware\MaskedCsrfToken::class,
]);
})
On older apps that still have app/Http/Middleware/VerifyCsrfToken.php, put the same getTokenFromRequest() override in that class instead.
Finally, a Blade directive in AppServiceProvider::boot():
use Illuminate\Support\Facades\Blade;
Blade::directive('maskedCsrf', function () {
return '<?php echo \'<input type="hidden" name="_token" value="\'.e(\App\Support\MaskedCsrf::token()).\'" autocomplete="off">\'; ?>';
});
Replace @csrf with @maskedCsrf in your forms, and use {{ \App\Support\MaskedCsrf::token() }} in any <meta name="csrf-token"> tag. If custom directives are new to you, the guide on setting variables in a Laravel Blade template covers how Blade compiles them.
Gotcha: plain @csrf keeps working because unprefixed tokens pass straight through. That makes migration easy, but every unmasked @csrf left in a layout is still a leak. Grep for it:
grep -rn "@csrf\|csrf_token()" resources/views
Masking only protects the CSRF token. Personal data and keys printed with @json stay static between requests, so they need one of the other mitigations below.
Stop reflecting input next to secrets
In the running example, the real problem is the search page printing q on the same page as the token. If the search page didn't include the logout form, or loaded it later with a separate request, there'd be nothing to guess.
This is design advice, not a config switch. It's still the cleanest option for pages like search results or error pages that echo the URL back to the user. A practical version: render the logout button as a small form that fetches its token from a dedicated endpoint, or move user details out of the shared layout on pages that echo the query string.
Keep the session cookie off cross-site requests with SameSite
If the victim's browser doesn't send the session cookie with the attacker's requests, the response contains a guest token, not the victim's. Laravel's config/session.php defaults to:
'same_site' => 'lax',
With lax, cross-site image loads, fetch() calls, iframes and POST forms don't carry the cookie. Top-level GET navigations still do, so an attacker opening windows can still get some requests through. Setting strict closes that gap, but users arriving from links on other sites will appear logged out on their first page load.
I treat lax as raising the bar, not a complete fix. Check that nothing in .env overrides it with SESSION_SAME_SITE=none, which some apps set for embedded widgets or payment redirects and then forget about.
Turn compression off only for cross-site requests
BREACH requests come from the attacker's page, so modern browsers mark them with Sec-Fetch-Site: cross-site. Your own pages, assets and API calls show same-origin or none. Skip compression only for the cross-site ones.
Apache (this also works in XAMPP), in your vhost or .htaccess:
<IfModule mod_setenvif.c>
SetEnvIfNoCase Sec-Fetch-Site "^cross-site$" no-gzip no-brotli
</IfModule>
mod_deflate honours no-gzip and mod_brotli honours no-brotli.
Nginx allows gzip inside an if block within a location, so put it where PHP responses are served:
location ~ \.php$ {
if ($http_sec_fetch_site = "cross-site") {
gzip off;
}
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
If you use the Brotli module, add brotli off; in the same block. if inside a location has a reputation for odd behaviour in Nginx, so test this with the curl commands below before trusting it. Older browsers don't send Sec-Fetch-Site, so they still get compressed responses. That's why this goes alongside masking, not instead of it.
What selective compression costs you
A visitor who clicks a link from a search engine or another site arrives with Sec-Fetch-Site: cross-site too. That first HTML document is served uncompressed. Everything the page then loads (CSS, JavaScript, images, your own API calls) comes from your origin and stays compressed, and so does every page they visit after that.
So the cost is one larger HTML response per external visit, not a slower site. For most apps that's a fair trade. If landing page size matters a lot to you, keep compression for top-level navigations and rely on masking plus SameSite for that path:
map "$http_sec_fetch_site:$http_sec_fetch_mode" $breach_nogzip {
default 0;
"~^cross-site:(?!navigate)" 1;
}
Put the map in the http block and change the condition inside the location to if ($breach_nogzip) { gzip off; }. This is a judgement call. You're accepting that attacker-opened windows get compressed responses, so only do it once tokens are masked.
Watch out for the CDN re-compressing
If Cloudflare or another CDN sits in front of the site, it may compress the response itself, even when your origin didn't. Many CDNs skip compression when the origin sends Cache-Control: no-transform. Check your provider's docs and test from outside rather than assuming. If you're already tuning Cloudflare for a Laravel app, this post on the Cloudflare JS challenge breaking a Laravel SPA covers other edge settings that interfere with your origin.
BREACH mitigations that look good but don't hold up
Random length padding. Adding a random number of bytes to each response adds noise, but averaging over more requests removes it. It slows the attack and doesn't stop it.
Rate limiting. It helps against a slow attacker, but the requests come from the victim's browser and IP, so tight limits hurt real users before they stop anyone.
Disabling gzip site-wide. It works, and it's the worst trade available. Static files are not BREACH targets, because they contain no secrets and reflect no input. Keep them compressed.
When you shouldn't bother
Not every site needs this. If no response contains both a user-specific secret and reflected input, BREACH has nothing to extract. A static marketing site, a docs site without logins, or a JSON API that takes a bearer token in the Authorization header and never echoes it back is not a realistic target.
Masked tokens also make every page unique. That's fine for authenticated pages, which shouldn't be in a shared full-page cache anyway. If you cache guest pages that contain forms, you're already serving one token to many users, which is a separate problem to fix first.
Verifying the BREACH scanner warning is actually addressed
Check compression for a normal request, then for a cross-site one:
curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, br' \
'https://example.com/search?q=test' | grep -i content-encoding
curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, br' -H 'Sec-Fetch-Site: cross-site' \
'https://example.com/search?q=test' | grep -i content-encoding
The first should print content-encoding: gzip or br. The second should print nothing. If you used the map variant, add -H 'Sec-Fetch-Mode: cors' to the second command so it matches the non-navigation case.
Then confirm masking. Use a cookie jar so both requests share one session, otherwise the token changes simply because the session is new:
curl -s -c jar.txt -b jar.txt https://example.com/login | grep -o 'name="_token" value="[^"]*"'
curl -s -c jar.txt -b jar.txt https://example.com/login | grep -o 'name="_token" value="[^"]*"'
Both values should start with m. and be different. Finally, submit a real form in the browser to make sure validation still passes. A 419 "Page Expired" here means a form is posting a masked token while the old middleware is still active.
One thing to expect: testssl.sh will keep reporting BREACH as long as any compression is on, because it can't see your token masking. Write down the mitigations you applied so the next audit doesn't undo them. If you want a second pair of eyes on your setup, you can contact HeadsNTail.
FAQ
Is BREACH still a risk with HTTP/2 and TLS 1.3?
Yes. BREACH targets compressed HTTP response bodies, not TLS, so newer TLS versions don't change it. HTTP/2 compresses headers differently but still serves gzip or Brotli bodies when the client asks.
Does Laravel protect against BREACH by default?
Partly. The default same_site => 'lax' session cookie blocks most cross-site subresource requests. The @csrf field prints the raw session token, though, so masking is something you add yourself.
Should I disable gzip to fix CVE-2013-3587?
Only if you can't do anything else. Masking secrets, keeping reflected input away from secrets, and skipping compression for cross-site requests let you keep compression for everything else.
Are static files like CSS and JavaScript vulnerable to BREACH?
No. A BREACH target needs a secret in the body and attacker-controlled input in the same response. Static assets have neither, so keep compressing them.
Can BREACH affect JSON API responses?
It can, if the API uses cookie authentication, echoes request input, and returns a secret in the same compressed response. APIs that use bearer tokens in the Authorization header are much harder to target, because the browser doesn't attach that header to the attacker's cross-site requests.
Start with the masked token and the SameSite check; that's the fastest way to mitigate BREACH attacks without saying goodbye to compression.
HNT-END
Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.
Top comments (0)