Why I Started Looking at nftables Rate Limiting
I run several services through an Nginx reverse proxy on a Linode VPS. These services sit behind a WireGuard tunnel because my home network is stuck behind CGNAT. The setup works well for routing traffic, but it also means my VPS is directly exposed to the internet—and that exposure comes with risks.
In late 2023, the HTTP/2 rapid reset vulnerability became a real concern. Attackers could send a flood of HTTP/2 requests and immediately reset them, overwhelming servers without completing actual connections. Rate limiting at the application level (like Nginx's limit_req) helps, but it only kicks in after the connection reaches Nginx. By that point, the kernel and network stack have already done work processing those packets.
I wanted a layer of protection that operated earlier—at the firewall level—before packets even reached Nginx. That's where nftables came in.
My Existing Setup
My Linode server runs Debian with nftables as the firewall. The server handles:
- Port forwarding from the public IP to services behind WireGuard (192.168.69.2)
- NAT for both IPv4 and IPv6
- Nginx reverse proxy listening on ports 80 and 443
The basic nftables config I had was functional but minimal. It handled NAT and allowed traffic through specific ports. What it didn't do was limit the rate of incoming connections or protect against rapid connection attempts.
What I Actually Implemented
I added rate limiting rules directly in nftables to drop excessive connection attempts before they reached Nginx. This operates at the packet filter level, which is more efficient than application-level rate limiting for this type of attack.
The Rule Structure
I created a new chain in my filter table to handle rate limiting for HTTP and HTTPS traffic:
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# Allow established connections
ct state established,related accept
# Rate limit new HTTP/2 connections
tcp dport { 80, 443 } ct state new limit rate over 10/second burst 20 packets drop
# Allow remaining new connections
tcp dport { 80, 443 } ct state new accept
}
}
How It Works
The rule targets new TCP connections on ports 80 and 443. If more than 10 new connections per second arrive (with a burst allowance of 20 packets), nftables drops the excess packets immediately. Established connections are not affected—only new connection attempts are rate-limited.
This approach stops rapid reset attacks at the kernel level. An attacker sending thousands of connection requests per second will hit the limit and have their packets dropped before Nginx ever sees them.
Testing the Rules
I tested the rules using ab (ApacheBench) from a remote machine to simulate a high volume of requests:
ab -n 10000 -c 100 https://mydomain.com/
Without rate limiting, all 10,000 requests reached Nginx. With the nftables rules active, the connection attempts beyond the rate limit were dropped, and the server remained responsive. I could see the dropped packets in the nftables counters:
nft list ruleset | grep drop
The counter incremented as expected when the limit was exceeded.
What Didn't Work
My first attempt used a global rate limit across all traffic, not just new connections. This caused legitimate users with multiple tabs or assets loading simultaneously to get blocked. I had to adjust the rule to only target ct state new to avoid disrupting established sessions.
I also tried setting the rate too low initially (5 connections per second). This was fine for a single user, but it broke normal browsing behavior when multiple resources loaded at once. I settled on 10 connections per second with a burst of 20, which accommodates typical browser behavior without allowing abuse.
Another issue was that nftables rate limiting applies per rule, not per IP. If I wanted per-IP rate limiting, I would need to use a named set with dynamic entries, which is more complex. For my use case, a global rate limit was sufficient because the services I expose are low-traffic and not public-facing APIs.
Combining with Nginx Rate Limiting
I kept Nginx's limit_req rules in place for application-level rate limiting. The nftables rules handle rapid connection floods, while Nginx handles per-endpoint limits (like login pages). The two layers work together:
- nftables: Drops excessive new connections at the packet level
- Nginx: Limits request rates per endpoint and per IP
This layered approach means an attacker has to bypass both the firewall and the application logic to cause harm.
Monitoring and Adjustments
I monitor the nftables counters periodically to see if the rate limit is being triggered:
nft list chain inet filter input
If I see consistent drops, I check the logs to determine if it's an attack or a misconfiguration. So far, I've only seen occasional spikes during automated scans, which the rules handle without issue.
I also keep an eye on Nginx's error logs to see if legitimate traffic is being affected. If users report connection issues, I can adjust the rate limit or add exceptions for specific IPs.
Key Takeaways
Rate limiting at the firewall level is effective for protecting against connection-based attacks like HTTP/2 rapid reset. It stops abuse before it reaches the application, reducing load on Nginx and the backend services.
The rules need to be tuned carefully. Too strict, and you block legitimate users. Too loose, and you don't stop the attack. Testing with realistic traffic patterns is essential.
nftables rate limiting is not per-IP by default. If you need per-IP limits, you'll need to use dynamic sets or a more complex ruleset. For low-traffic services, a global limit works fine.
This approach complements application-level rate limiting but doesn't replace it. Both layers are necessary for a complete defense.