DEV Community

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

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

First published on medium
One overlooked detail in request inspection can leave your security checks blind to real attacks.
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
Enter fullscreen mode Exit fullscreen mode

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

' OR '1'='1
Enter fullscreen mode Exit fullscreen mode

Arrives at your PHP server looking like this:

%27%20OR%20%271%27%3D%271
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A double-encoded payload:

%2527  →  urldecode()  →  %27  →  still encoded, not detected
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.
Sleep better we're awake.

Top comments (0)