← Back to list

Why URL Encoding Can Break PHP Security Checks — and What Actually Works

One overlooked detail in request inspection can leave your security checks blind to real attacks.

Nchiminyi Jezreel in System Weakness · 2026-07-07 10:23 · 19 claps · 5.4 min read
#security #php #laravel #web-development #programming
Open on Medium ↗
Wiki topics: ML · Machine Learning 💻 · Programming 🌐 · Web Development

Why URL Encoding Can Break PHP Security Checks — and What Actually Works

One overlooked detail in request inspection can leave your security checks blind to real attacks.

kriosa.com Sleep better we’re awake

kriosa.com Sleep better we’re awake

Last week I published an article about SQL injection patterns in PHP logs.

A developer read it carefully enough to find a real problem in the code snippet I included. The pattern matching I showed would not catch a real attack because of one thing: URL encoding.

That comment made me realize this topic deserves its own article. Because the gap he found is not unique to my code. It is in many PHP security checks developers write themselves.

Here is the full picture.

What URL Encoding Is

When a browser or attacker sends data through a URL, certain characters cannot travel as-is. Characters like single quotes, spaces, and equals signs have special meaning in URLs they separate parameters, indicate values, structure the request.

So they get converted to a safe format before transmission. This conversion is called URL encoding or percent encoding.

The rules work like this:

'       →  %27
(space) →  %20' OR '1'='1
=       →  %3D
"       →  %22
/       →  %2F

So a SQL injection payload that looks like this in plain text:

' OR '1'='1

Arrives at your PHP server looking like this:

%27%20OR%20%271%27%3D%271

The same payload intent. A completely different representation. And many PHP security checks never see it coming.

Why Some Security Checks Fail

Here is the kind of security check many PHP developers write when they want basic protection:

$suspicious = ["'", "union", "select", "drop", "insert", "--", "/*"];
$query = strtolower($_SERVER['QUERY_STRING'] ?? '');

foreach ($suspicious as $pattern) {
    if (strpos($query, $pattern) !== false) {
        error_log("SUSPICIOUS REQUEST: " . $_SERVER['REMOTE_ADDR']);
    }
}

This looks reasonable. It checks for common SQL injection patterns and logs suspicious requests.

But look at what it is checking against: $_SERVER['QUERY_STRING'] the raw query string exactly as it arrived from the browser.

If the attacker sent %27%20OR%20%271%27%3D%271, that raw string contains none of the patterns you are checking for. No single quote. No OR. No equals sign. The check runs, finds nothing suspicious, and the request passes through.

The detection check misses the request. Your log may show nothing suspicious.

Important note: if you access input through $_GET rather than $_SERVER['QUERY_STRING'], PHP already decodes the values for you. This issue specifically affects developers inspecting raw request data directly.

Security reminder: this example is for demonstrating detection concepts. It is not a replacement for using parameterized queries and prepared statements. The real fix for SQL injection is preventing it at the database layer not filtering it at the input layer.

Normalizing Input Before Inspection

The solution is to decode the URL encoding before running your pattern matching. To avoid decode bomb attacks where deeply nested encoding burns CPU we cap the decode passes at a fixed number:

$suspicious = ["'", "union", "select", "drop", "insert", "--", "/*"];

// Cap decode passes to prevent decode bomb attacks
$query = $_SERVER['QUERY_STRING'] ?? '';
$maxPasses = 3;
$i = 0;
$previous = null;

while ($previous !== $query && $i < $maxPasses) {
    $previous = $query;
    $query = urldecode($query);
    $i++;
}

$query = strtolower($query);

foreach ($suspicious as $pattern) {
    if (strpos($query, $pattern) !== false) {
        error_log("SUSPICIOUS REQUEST: " . $_SERVER['REMOTE_ADDR']);
    }
}

Three passes maximum. The loop stops earlier if the string stops changing. This handles single and double encoding which covers the vast majority of real-world evasion attempts without opening an infinite loop vulnerability. This fixes a detection blind spot. It does not fix SQL injection itself that requires parameterized queries at the application layer.

But It Goes Deeper: Double Encoding

Sophisticated attackers know about urldecode(). So they encode twice.

A single-encoded payload:

%27  →  urldecode()  →  '  ✓ detected

A double-encoded payload:

%2527  →  urldecode()  →  %27  →  still encoded, not detected

The %25 is the URL encoding for the % character itself. So %2527 decodes to %27 which is still encoded. Your single urldecode() call produces %27, your pattern matching looks for ', they never match.

Running urldecode() twice helps in some cases. But be careful repeated decoding without understanding your full request stack can cause unexpected behavior. Know where decoding already happens in your framework before adding more layers. PHP frameworks like Laravel may already decode input at certain points in the request lifecycle.

Why Pattern Matching Has Limits

Even with proper URL decoding, pattern matching has a fundamental weakness: it only catches what you know to look for.

Attackers constantly develop new payloads specifically designed to bypass known patterns. They use alternative syntax. They use database-specific encoding. They use comments to break up keywords:

SE/*comment*/LECT * FR/*comment*/OM users

Your pattern matching looks for select. It never appears as a contiguous string. The check may pass. Whether the attack succeeds depends on how the application processes the input afterward.

This is the core limitation of signature-based detection: it catches known bad patterns. It misses unknown ones.

What Actually Works: Behavioral Detection

A developer who commented on my last article made a point worth examining carefully:

Response size changes can be a valuable behavioral signal. A 200 response returning 9kb where 512 bytes is normally expected may indicate unusual behavior and deserves investigation.

He is right. This points to a completely different detection approach: anomaly detection.

Instead of asking “does this request contain a known bad pattern?” anomaly detection asks “does this request behave abnormally compared to what we normally see?”

A response significantly larger than normal for that endpoint is worth investigating regardless of what the payload looked like. That said, larger responses can have legitimate causes too. The signal is most useful when combined with other indicators, not treated as conclusive on its own.

Anomaly detection adds a layer that signature matching alone cannot provide it examines behavior over time, not just individual request patterns.

The Two Approaches Combined

The strongest security systems use both:

Signature detection catches known attack patterns quickly and cheaply. Good for stopping automated scanners running common payloads.

Anomaly detection can help identify unusual activity and attacks that do not match known signatures. Good for examining behavior patterns that signature matching alone would miss.

Neither is sufficient alone. Together they cover the gaps the other leaves open.

This is the type of gap behavioral analysis is designed to help identify by looking beyond individual patterns and examining how activity behaves over time.

This is the direction behind Kriosa’s approach combining rule-based detection with behavioral analysis and explainable security insights. Not relying on one technique alone, but layering them so each covers what the other misses.

The Practical Takeaway

If you are writing your own PHP security checks:

Know your input source understand the difference between $_GET and $_SERVER['QUERY_STRING'] and what each contains before you inspect it. Normalize before matching use the capped decode loop shown above, not a single urldecode() call. Single decoding misses double-encoded payloads.

// Cap decode passes to prevent decode bomb attacks
$query = $_SERVER['QUERY_STRING'] ?? '';
$maxPasses = 3;
$i = 0;
$previous = null;

while ($previous !== $query && $i < $maxPasses) {
    $previous = $query;
    $query = urldecode($query);
    $i++;
}

$query = strtolower($query);

Watch for behavioral signals response sizes significantly larger than normal for a given endpoint are worth investigating alongside other indicators.

Understand the fundamental limit: manual pattern matching is a starting point, not a complete security strategy. It catches careless automated attacks. It struggles against careful targeted ones.

And remember: the best security strategy is layered prevent vulnerabilities where possible, detect suspicious activity when prevention fails, and continuously improve based on what you learn.

A Note on How This Article Happened

This article exists because a developer read my previous article carefully enough to find a real gap in the code and said so publicly. That kind of technical engagement is rare and genuinely valuable.

The best security knowledge comes from exactly this kind of exchange someone pushing on the detail, finding the weakness, and making the conversation better.

If you find something wrong in this article, say so in the comments. That is how this gets better.

Try Kriosa free: kriosa.com Install it: composer require kriosa-ai/kriosa-php

Built by a developer from Cameroon, for developers who want to understand their security not just outsource it.


메타데이터
post_id
748d2ebc848e
slug
why-url-encoding-can-break-php-security-checks-and-what-actually-works-748d2ebc848e
url
https://systemweakness.com/why-url-encoding-can-break-php-security-checks-and-what-actually-works-748d2ebc848e
canonical_url
https://systemweakness.com/why-url-encoding-can-break-php-security-checks-and-what-actually-works-748d2ebc848e
author_url
https://medium.com/@jnchiminyi
status
ok
fetched_at
2026-07-11 16:48:19