Possible Exfiltration
Noticing when far more data leaves a site than normally does — in one answer, or added up at one address. What each threshold does, what it measures, and what it cannot see.
Possible exfiltration notices when far more data leaves a site than normally does — either in one answer, or added up at one address. It never blocks. At the byte level a theft and the quarterly export are the same event, so it raises a finding for a person to judge and refuses nothing.
The question it answers, and the one it does not
Response Scanning reads response bodies for recognisable secrets: card numbers, social security numbers, API keys, private keys. It is good at that and blind to the ordinary breach. It skips anything compressed, reads four content types, stops at a size limit, and matches patterns — so a customer table of names, addresses, order history and password hashes matches nothing at all. Email detection is off by default, and the rest of those columns have no pattern to match.
Counting bytes has none of those limits. It works on gzip, because a byte count needs no decompression. It works on data with no pattern, because it never looks inside. And it adds up across requests, so a thousand pulls of one row each stop being invisible.
It cannot see data taken another way. A
mysqldumppiped tocurl, a reverse shell, exfiltration over DNS, a stolen backup — none of those pass through nginx. EdgeGuard sits in front of inbound HTTP and never sees what your application sends outwards on its own. This measures what leaves through the website, and nothing else.
The two findings
| Finding | What it means |
|---|---|
| Possible exfiltration — one large response | A single answer was far bigger than this endpoint has ever sent |
| Possible exfiltration — one IP over time | Many perfectly ordinary answers, adding up at one address far beyond what other addresses pull |
They are deliberately separate, because they need different responses. The first points at one request — look at what it returned. The second points at a client — look at what it has been doing for the last ten minutes.

The action column reads Observed for both: the request was served and a finding recorded. Nothing was refused, and the column that says what was done says so honestly. Which of the two findings it is stands in the Rule column.
Reading a finding

Every finding carries the figures that justify it, because "unusual volume" with no numbers is not something anybody can act on or dismiss:
{
"response_volume": {
"kind": "client_volume",
"bytes": 69000000,
"baseline": 21657,
"ratio": 3185,
"window": "10m0s",
"human": "this address pulled 65.8 MB in 10m0s, against 21 KB for a typical address on this site",
"user_agent_source": "last request seen from this address"
}
}
- bytes — how much left, measured before compression.
- baseline — what is normal. For a large-response finding, what that endpoint usually sends. For an address finding, what a typical address on that site pulls in the same period.
- ratio — the first divided by the second.
- window — only on an address finding; a single response has no period.
- user_agent_source — the response that was measured carries no user agent, so the agent reports what that address was last seen using. It is an attribution, not a measurement, and the field says so.
Severity comes from the score. An address pulling far above what the site's others pull scores 10 (high). A single answer a thousand times its baseline scores 10. Anything else that passed the thresholds scores 5 (medium). Nothing is ever critical — critical should mean an attack confirmed, and this signal cannot tell a theft from a legitimate export.
Where the numbers come from
The nginx module counts the bytes of every response from its body filter and reports them to the engine over the local socket. It is counted above gzip, which matters: a 50 MB table compresses to about 5 MB, and the compressed figure would put a 600 MB dump under every threshold.
nginx module → unix socket → engine → per-endpoint baseline → finding
Reporting is on by default and costs one short socket write per response, made after the client has been served, with a connect that gives up immediately if the engine is busy. To switch it off for a location:
security_response_feedback off;
That directive is the emergency brake: it removes the measurement from the response path with a reload and no rebuild. It also blinds three other things that read the same signal — the origin load measure, the circuit breaker, and 404-enumeration detection — so it is not the same as switching the finding off in the console.
Paths are grouped by endpoint, not by identifier. /api/orders/48213 and /api/orders/91002 are both /api/orders/:id. Without that, every order id would be its own endpoint with one observation, nothing would ever earn a baseline, and the tracker would be a memory leak that detects nothing.
Learning before judging
An endpoint is watched and never reported until it has served 50 responses. Until then there is nothing to compare against: a first request is infinitely above a baseline that does not exist.
The per-address signal needs more: 50 closed windows of ten minutes, which is over eight hours of traffic. Both counts survive a restart — they are written to the agent's database every five minutes and read back at startup. Before that they lived only in memory, and a node restarted daily could never reach the eight hours the address signal needs.
You can see where a site stands:
curl -s http://127.0.0.1:8080/status | python3 -m json.tool | grep -A 30 response_volume
"observations": 2850, how much history this endpoint has
"observations_until_judged": 0, 0 means it can be judged
"mean_bytes": 244, what normal looks like here
"mean_deviation_bytes": 88, and how much it varies
"requests_decided": 53, this process only, comparable with the next
"counted_since_start": 60
An empty findings list usually means "nothing can be judged yet", not "nothing is happening". That is why the figures above say which.
client_windows_until_judgedanswers the same question for the address signal.
The baseline adapts. It is an exponentially weighted average, so a sustained change is absorbed: seventy-five identical 3 MB responses produce one finding, not seventy-five, because after about thirty the endpoint's normal is 3 MB. That is right for spotting one anomalous dump and it is a real limit for slow, sustained extraction — which is what the per-address window exists to catch instead. Neither signal covers the other.
Configuration
Global Settings → Data Protection → Response Volume, and the same card on a group, a tenant or a path rule. Everything inherits unless you set it, and leaving a field empty means "use the default" — setting one threshold never zeroes another, so an installation that never opens this page behaves exactly as it did.

One response, against that endpoint's own history

There are two pairs of settings, and a response is reported when it clears either pair. One pair alone cannot serve both a site whose endpoints answer a few hundred bytes and one whose endpoints answer megabytes.
Large response floor — default 1048576 (1 MiB)
How big an answer must be before the modest multiple below applies. Nothing under this size is ever reported through this pair, whatever the multiple.
It exists so that a tiny endpoint growing a little is not news: 200 bytes becoming 4 KB is twenty times normal and still nothing worth a person's attention.
- Raise it if the site routinely serves large files and you only care about the truly enormous. At
10485760(10 MiB) a 5 MB download can never be reported through this pair. - Lower it and you lean on the multiple instead — at
102400a 200 KB answer from a 10 KB endpoint starts being reported.
Multiple of normal — default 20
How many times the endpoint's usual size, for answers above that floor. With the defaults, an endpoint that usually sends 2 MB is reported at 40 MB.
- Raise it to
50and that same endpoint stays quiet until 100 MB. - Lower it to
5and a report endpoint sending 10 MB instead of 2 MB is reported — probably every month end.
Moderate response floor — default 65536 (64 KiB)
A second, lower floor — for answers that are an extreme multiple of normal rather than a modest one.
This is the pair that makes the signal work at the scale most sites actually live at. On a real appliance the API endpoints answer 36 to 290 bytes: a thousand records pulled through a 290-byte endpoint is 290 KB, a thousandfold, and the 1 MiB floor says nothing at all about it.
- Raise it to silence smaller anomalies entirely.
- Lower it to
8192and a 10 KB answer from a 40-byte ping endpoint — 250 times normal — starts being reported.
Extreme multiple — default 200
How many times the usual size, for answers above the moderate floor. The pair is deliberately strict: a modest multiple of a small number is still a small number.
- Raise it to
1000and only the thousandfold cases are reported. - Lower it to
50and you will see far more, including legitimate variation on endpoints that are not as consistent as they look.
Outside its own spread by — default 8
How far outside the endpoint's normal variation, measured in average deviations rather than a fixed size. This applies to both pairs.
It is what keeps an endpoint whose responses genuinely vary from reporting its own ordinary range. One real endpoint reads a mean of 536 bytes with a deviation of 801 — wider than the mean, and entirely legitimate. Without this test, ratio alone would report its ordinary high days.
- Raise it to
15for a site full of variable endpoints. - Lower it to
3only if you have a very consistent site and want finer sensitivity; on a variable one it produces noise.
An endpoint whose answers are always identical has a deviation of zero, so this test passes trivially and the floors and multiples do the work. That is correct: a perfectly constant endpoint suddenly sending something different is the signal.
One address, over a window

This is the signal for data leaving in pieces that each look entirely ordinary — which is what per-response thresholds can never catch, because no single response is unusual.
Address total floor — default 67108864 (64 MiB)
How much one address must pull within the window before it is reported. An absolute amount, so a quiet site does not report its busiest ordinary visitor.
- Raise it on a site with legitimate bulk downloads — this is the setting most likely to need it. At
536870912(512 MiB) only a serious pull is reported. - Lower it on an API that should never send much to anyone: at
10485760(10 MiB) a client taking ten megabytes in ten minutes is worth seeing.
Multiple of a typical address — default 10
How many times what the site's other addresses pull in the same period. This is a comparison with the site itself, not a fixed number, so it adapts to how the site is actually used.
Both this and the floor must be cleared. On a real appliance a typical address pulls about 21 KB in ten minutes, so the floor is almost always the binding constraint — but on a media site where everyone pulls hundreds of megabytes, this is what stops every visitor being reported.
- Raise it to
50on a site with very uneven but legitimate usage. - Lower it to
3on a site where everyone should behave about the same.
Window (seconds) — default 600
The period one address's total is measured over. Reported once per window, not once per response — once the threshold is crossed, every later response in that window crosses it too, and reporting each would turn a single pull into hundreds of findings.
- Raise it to
3600to catch extraction spread over an hour; the floor then has to cover an hour of legitimate use too. - Lower it to
60to catch sharp bursts and miss anything slower.
The window in flight is lost when the engine restarts, deliberately: restoring a half-finished window would make the first comparison after a restart wrong in a way nobody could reconstruct. The learned baseline survives; the ten minutes in progress do not.
Learning
Both signals compare against what a site normally does, so both need a period of watching before they can judge anything. This setting governs how long that is, and it is the difference between a quiet console and a console that alarms on every new endpoint.

Observations before judging — default 50
How many responses an endpoint must have served before it can be compared with anything. Until then it is watched and never reported.
Without it, the first request to any new endpoint is infinitely above a baseline that does not exist, and every new path would alarm once.
- Raise it to
200on a site with very variable traffic, so a baseline is better established before anything is judged. The cost is a longer blind period on a new endpoint. - Lower it to
20to start judging sooner, at the risk of a baseline built from too few samples — and a baseline built from five quiet responses makes the sixth ordinary one look anomalous.
This same number governs the address signal, where it counts closed windows rather than responses: fifty ten-minute windows is over eight hours of traffic before the first address can be judged. Both counts survive a restart.
Seeing them together

Analytics carries a panel counting the findings for the period, with a link into the filtered list. In Security Events, the Attack Type filter offers them together and separately, and the global Action filter offers Observed.
Zero findings on an ordinary day is the correct result. This is a rare signal by design: thresholds that cry wolf get muted, and a muted signal is worse than none because the console still says it is watching.
Proving it works
On a site you operate. Serve two files under one path so they share a baseline — numeric names group to :id:
location /eg2/ {
alias /var/www/eg2/;
default_type text/plain;
}
head -c 120 /dev/urandom | base64 | head -c 120 > /var/www/eg2/1
head -c 3000000 /dev/urandom | base64 | head -c 3000000 > /var/www/eg2/2
UA='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
# 60 small answers build the baseline at 120 bytes
for i in $(seq 60); do curl -ks -A "$UA" https://example.com/eg2/1 >/dev/null; done
# one large answer: 25,000 times that baseline
curl -ks -A "$UA" https://example.com/eg2/2 >/dev/null
A Possible exfiltration — one large response finding appears within seconds.
Send a browser user agent. Bot detection answers
curlwith a block page, and the baseline would then be built from block pages rather than your files — the test would measure the wrong thing and look like it had failed.
For the address finding, use only the large file so the baseline settles at its size and the per-response tier stays quiet:
for i in $(seq 25); do curl -ks -A "$UA" https://example.com/eg2/2 >/dev/null; done
Twenty-five times three megabytes crosses the 64 MiB floor, and one finding appears — not twenty-five.
Choose sizes against both tiers. "1 MB" written as 1,000,000 bytes is below 1 MiB (1,048,576) and clears neither floor; three megabytes clears both comfortably.
When nothing arrives
curl -s http://127.0.0.1:8080/status | grep -o '"responses_reported":[0-9]*'
If that number is not climbing, nginx is not reporting. The module says why, once per worker, in the error log:
grep "security response feedback" /var/log/nginx/error.log | tail -4
| It says | What to do |
|---|---|
security_response_feedback is off for this location |
It defaults to on, so it was turned off somewhere in this configuration |
no security_tenant_id is in effect |
The location serving the response has no tenant; an internal redirect wipes the request context, so the tenant has to come from configuration that location can see |
could not reach the engine without waiting |
The engine's socket backlog was full and the report was dropped rather than made the visitor wait |
The module logs these at error level on purpose. nginx's default
error_loglevel iserror, so a line written at warn — which is less severe — is discarded by a plainerror_log /var/log/nginx/error.log;, and the log stays empty however loudly the code speaks.
If responses are arriving but an endpoint is never judged, compare the columns on /status: requests_decided beside counted_since_start. They should move together, with counted a little higher — some requests are answered from the module's own decision cache without asking the engine, and the response is counted anyway. Decided much larger than counted is the fault worth chasing.
What it is not
- Not a breach detector. It sees what leaves through the website. A database taken from the server itself never passes the WAF.
- Not a verdict. A legitimate export looks exactly like a theft at the byte level. Every finding needs a person.
- Not a block. It refuses nothing, by design: blocking on volume would break every legitimate export, and the feature would be switched off within a day.