Why a WAF cannot see a SQL injection decoded inside the app

A WAF cannot see a SQL injection that is decoded inside the application because it inspects the request as it crosses the network edge, and the injection only becomes SQL after the application decodes the payload, assembles it from harmless pieces, or reads it from somewhere that is not an HTTP request. Nothing on the wire matches a signature, so the request passes and the dangerous string exists only inside the process.

This article explains the structural reasons by category, with no vendor named, and what kind of visibility does see these flows. A WAF is a useful control and this is not an argument against running one. It is an argument about where it can and cannot see, so you can reason about the gap instead of assuming there is none.

What an edge filter actually inspects

An edge filter sits in front of the application and examines incoming HTTP traffic for known attack payloads and abnormal patterns. The decision is made on the request as received: headers, the query string, the body, as bytes on the wire. A managed SQL injection ruleset, for example, recognizes shapes like a quote followed by a boolean tautology and a comment marker, and blocks the request.

The strength and the limit are the same fact. The filter sees exactly what arrives, and nothing about what the application then does with it. Everything below assumes the filter is working perfectly against the traffic it can read. The problem is not evasion of the rules; it is that the dangerous string does not exist yet when the rules run.

When the payload is decoded inside the app

Applications routinely decode input before using it: base64 in a token or cookie, percent-encoding, a JSON string field, a compressed or custom-encoded blob. If a value is benign in its encoded form and only becomes a SQL metacharacter sequence after the application decodes it, the request on the wire carries no signature.

import base64

def search(request):
    raw = request.GET["q"]                 # benign on the wire
    term = base64.b64decode(raw).decode()  # becomes live SQL only here
    with connection.cursor() as cur:
        cur.execute("SELECT id FROM item WHERE name = '" + term + "'")  # sink

The edge filter inspected raw, which is an innocuous base64 string. The injection exists only after b64decode, inside the process, where the filter has no presence. The same holds for any transform the application applies: URL-decoding, unwrapping a JSON field, replacing placeholder tokens, or decompressing.

The fix for the code above is parameterization, and it is worth stating because it also explains why the code is the place to rely on. A bound parameter keeps the decoded term out of the SQL text entirely:

cur.execute("SELECT id FROM item WHERE name = %s", [term])

The point for this article is that whether or not the code is fixed, the filter could never have known: the attack was not in the request it saw.

When the payload is split across two fields

A signature matches a contiguous pattern in one place. If an injection is assembled inside the application from two or more inputs that are each harmless on their own, no single field is a signature.

def report(request):
    column = request.GET["col"]            # e.g. a column name
    op = request.GET["op"]                 # e.g. a comparison
    sql = f"SELECT * FROM doc WHERE {column} {op}"   # reassembled in-app: sink
    with connection.cursor() as cur:
        cur.execute(sql)

Each parameter alone passes any per-field inspection. The dangerous string is produced by concatenation after both have been accepted. An edge filter inspecting requests field by field has no pattern to catch, because the pattern does not appear until the application builds it. (Note also that column and op are SQL text, not values: parameters cannot fix this one, which is the subject of a separate article on identifier and alias injection.)

When the input never crosses the edge

An edge filter inspects HTTP and HTTPS as it passes the perimeter toward your origin. A great deal of what a Python backend processes does not arrive that way:

  • A background worker pulls a job off a broker (Celery, RQ, a queue) and processes attacker-influenced content with no HTTP request in scope.
  • An east-west call between internal services never traverses the perimeter the edge filter guards.
  • A scheduled task reads a file, an object store or a third-party API.
  • A non-HTTP protocol (a raw socket service, a message consumer) is simply not HTTP traffic.

In every case the data can still be attacker-controlled: a user uploaded the file, enqueued the job, or populated the record a worker later reads. But the request the edge filter is built to read either does not exist or already passed the perimeter earlier, stripped of the context that made it dangerous. Stored, second-order flows are the sharpest version: input accepted cleanly in one request is written to the database, then read back and used dangerously by a different code path minutes or days later.

The through-line

These are not three unrelated gaps. They are one property seen from three sides: an edge filter matches the request, and the dangerous value is a product of what the application does after the request is accepted. Decoding, reassembly and off-HTTP input all move the moment-of-danger past the edge. The 2025 OWASP Top 10 keeps Injection at A05 with the highest CVE count of any category, so this is not a corner case.

Other categories of tool have their own version of the same limit. Static analysis reads source without running it, so a payload that only exists after a runtime decode is invisible to it. Dependency scanners know which installed packages have CVEs, but not whether attacker data ever reaches the flaw, and they have nothing to say about a bug in your own code. Agents scoped to the HTTP request lifecycle do not follow a job a worker pulls off a queue. Each watches a place that is not the place where untrusted data meets the dangerous operation.

What does see these flows

The one position from which decoding, reassembly and off-HTTP origin all become irrelevant is the dangerous operation itself. At the instant the program issues the query, runs the command or opens the path, the final string exists, fully assembled, however it got there. A detector that checks the data at that operation does not care whether the payload was base64 in a cookie, split across two form fields, or carried in a broker message, because by then it is one concrete argument to one call.

That is where es-python, the EyalSec Python runtime, looks. It follows untrusted data through the program as it runs, from the source where it entered, through the decoding and concatenation your code performs, to the operation that uses it, and it checks it at the operation itself. An attacker cannot encode around it, because there is nothing in between to encode around. The evidence it produces is a real flow that happened on real input, with the path from entry to use, not a match on a signature. See How EyalSec works and the SQL injection overview for the model, and the taint sources page for the inputs it can treat as untrusted, including data read back out of a database, which is how second-order flows are caught.

Keep the edge filter. It stops a large volume of unsophisticated traffic cheaply. Just keep "the WAF passed it" apart from "the application was not attacked". If you want to see which flows reach a sink on your own services, including the worker and east-west paths an edge never sees, you can book a live demo.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo