Skip to content

Why blocking itself becomes the load

"We block the attack" is not a specification. The question is which layer performs the block, and what it costs you.

DDoS
/
2026-07-22
/
8 min read

A refused request is not free

A request blocked at the application layer travels the whole path first: the TCP connection is accepted, the TLS handshake completes, HTTP headers are parsed, the rule fires, and a 403 is written.

So for every request you see as "blocked", the server spent a connection, a key exchange and a response. For one request that is nothing. For a hundred thousand per second it is the reason the site went down.

The most expensive part is TLS. In an RSA-2048 handshake the server’s signing operation costs several times what the client spends: that asymmetry is not malice, it is the shape of the protocol. ECDSA P-256 is considerably cheaper, and still orders of magnitude dearer than accepting a packet.

Attackers know this. Which is why some of the most effective L7 attacks never send a request at all: the connection opens, TLS begins, and it stops there. From your side that is not even a "blocked request" — it never reaches the counter, while it holds memory and a file descriptor.

Which is why the graph lies

During an attack the dashboard reads "blocked requests: 2.4M" and the defence looks like it is working. The site, meanwhile, does not load.

The reason is simple: what is being counted is blocks, and every block means work was done. The larger the number, the harder the server worked. A rising line is rising cost, not rising protection.

A useful dashboard puts two lines on one chart: blocked requests and CPU at the same moment. A third helps as well — open connections, because in many cases that is what runs out first rather than the processor.

None of this needs exotic tooling. `ss -s` prints open sockets in a line; `netstat -s` gives retransmit and drop counters. If `TCPBacklogDrop` or `ListenOverflows` climb during an attack, you are already against the queue limit, and no blocking rule affects that number.

Figure 1One request, two prices. The upper path pays for the connection, the handshake and the response before writing 403; on the lower one the packet never reaches the application.

What dropping in the kernel changes

Once a source has been identified, the decision must not travel upward. The packet is dropped at the lowest stage of the network stack — it never reaches the application, no TLS is negotiated, no response is written.

In practice there are several levels. The lowest is the NIC itself, or XDP: the packet is discarded in the driver, before a socket and before an `sk_buff` exists. Above it, nftables or iptables — after `conntrack` and routing. Highest of all, a rule inside the application.

The difference is not theoretical. Every stage means allocating another structure per packet, taking locks, switching context; for a packet dropped in XDP none of that runs. In practice that is an order of magnitude more packets on the same hardware.

The second gain is invisible and matters more: a dropped packet occupies no slot in the connection table. When `nf_conntrack` fills, new connections start being refused, and that applies to the whole server — the site under attack and its neighbour alike. Outages usually begin there.

Why not push everything into the kernel

The kernel sees a packet, not a session. It will never conclude that "this user added the same item to the basket eighteen times" — that conclusion requires context, and context lives upstairs.

An XDP program runs in a deliberately constrained environment: the eBPF verifier permits no loops, no dynamic allocation and no long execution. That is a condition rather than a shortcoming — it is exactly what lets the program keep up with line rate.

So the correct architecture has two layers. The decision is taken above, in the context of behaviour; enforcement happens below, where it is cheap. Detection should be clever, blocking should be blunt and fast.

A list connects the two: the logic above identifies a source and hands it down with an expiry. Technically that is usually an eBPF map or an `ipset`, because writing to either does not require reloading a rule set. The expiry is mandatory — a permanent blocklist includes real users within a year.

How long a blocklist entry should live

The lifetime of a blocked source encodes two different mistakes. Too short and the attacker simply returns, keeping detection busy forever. Too long and a thousand users behind a mobile carrier’s NAT lose access for hours.

The practical answer is an increasing expiry: short on first offence, longer on repeat. A user caught by accident is released quickly, while a source that keeps returning is pushed further out each time.

IPv6 changes the rule. A single host is usually given a /64, so blocking one address achieves nothing — the attacker moves next door. On IPv6 the block is applied per /64, sometimes per /48, and that decision has to be written down explicitly.

And every entry has to carry its reason: which rule, at what time, on what signal. A list without reasons becomes, within a month, a black box nobody dares touch — there is no answer to "why is this address here?", and removing it feels risky.

Figure 2Detection needs context; enforcement needs speed. Putting both on one layer sacrifices one of them.

L3/L4 and L7 attacks are not the same problem

A volumetric attack fills the pipe, and it is absorbed at the carrier level. The constraint is bandwidth, not CPU — which is why that attack cannot be stopped next to the server: the pipe is already full and your rule is at the far end of it.

The classic shapes are DNS or NTP amplification and SYN floods. The answers differ too: upstream scrubbing, diverting traffic with BGP, or `SYN cookies` (`net.ipv4.tcp_syncookies` on Linux), which answer without holding state once the queue overflows.

An application-layer attack looks nothing like it: modest traffic, well-formed requests, real TLS, a plausible User-Agent. But each one asks for an expensive page — a search, a report, a filtered catalogue. Here the database falls over rather than the link, and the network graph stays perfectly calm.

There is no single control for both. "We have DDoS protection" means nothing until it says which one — and in many proposals only the first is meant.

A temporary refusal is also a tool

Not every suspicious request has to be blocked. Slowing it down, or attaching a check that costs computation, is often cheaper: a real browser does not notice, a script sending thousands of requests loses its throughput.

This matters most where confidence is low. A wrong block loses the user entirely; a wrong delay costs them a few hundred milliseconds. The price of being wrong drops — which means the threshold can be set tighter.

At HTTP level this is a 429 with a `Retry-After` header — a standard mechanism that well-written clients obey. Badly written ones do not, and that difference is itself a useful signal: a client ignoring `Retry-After` is not a browser.

One condition applies: the delay must also be enforced somewhere cheap. Holding the connection open and stalling the response occupies your own descriptors. The wait has to be stateless — answering immediately and refusing the next request costs less than keeping a connection alive.

Figure 3During an attack, blocked-request count and CPU belong on the same chart. If they climb together, the block is being performed at the application layer.

How to test it

Ask a vendor one thing: does server CPU change for a blocked source? If CPU rises together with the "blocked requests" counter, the block is happening at the application layer.

Second question: is a TLS handshake performed for a request from a blocked address? That one is easy to check — run `openssl s_client -connect host:443` from the blocked address. If a certificate comes back, you are still paying for the most expensive part, and the block is only changing the response body.

Third question is about expiry: how long does a blocklist entry live, and what extends it? No answer means the list is cleaned by hand — which means it is not cleaned.

The fourth reveals the most: during a load test, where are dropped packets counted? If the vendor cannot produce that number at all, nothing is being dropped below the application layer.

All posts

Shall we talk?

Show us a domain and we will give you a first read on your current external posture. Nothing is changed at this stage.

Let’s start
Get in touch
Get in touch