While running workshops with ScaleCommerce in Berlin, we worked on a use case that many SecOps teams will recognise.
ScaleCommerce wanted a single pool of CrowdSec WAF instances to protect all of its customers. The raw performance and scaling aspects are relatively straightforward and well-documented. The real challenge was implementing proper multi-tenancy in the WAF configuration, especially the ability for customers to disable specific rules or sets of rules without disrupting operations.
In their case, this means hundreds of customers, thousands of websites, and a long list of custom rules tied to business logic. ScaleCommerce focuses on e-commerce, so it has a significant number of rules regarding scalping, bot protection, and similar behaviours.
From a SecOps point of view, the question was simple: how do you maintain a single shared CrowdSec WAF layer, provide each tenant with the necessary flexibility, avoid constant reloads, and still keep the entire system manageable?
The workshop led to two approaches.
Take 1: Configuration-based multi tenancy
The initial idea was to implement per-customer configuration directly in CrowdSec using hooks.
Here is a simplified example of what that looked like:
name: custom/disable-rules-per-customer
pre_eval:
- filter: |
let custom_scenarios = {
"some-shop.com": ["crowdsecurity/generic-wordpress-uploads-listing", "crowdsecurity/generic-wordpress-uploads-listing"],
"another-shop.com": ["crowdsecurity/generic-wordpress-uploads-listing"],
};
map(custom_scenarios[req.Header.Get(âHostâ)],
{
RemoveInBandRuleByName(#)
})
This snippet uses pre_eval hooks to customize, in real-time, the list of enabled or disabled rules for a given fully qualified domain name.
In practice, it does the following:
- It maps the requested domain name, for example, some-shop.com, to a list of rules that need to be disabled for that specific site.
- For
some-shop.comthe rulescrowdsecurity/generic-wordpress-uploads-listingandcrowdsecurity/generic-wordpress-uploads-listingare disabled, while foranother-shop.comonlycrowdsecurity/generic-wordpress-uploads-listingis disabled.
Under the hood, this relies on the expression language embedded in CrowdSec, along with the RemoveInBandRuleByName helper that disables the required rule or rules.
Even in this simple form, it reveals an important aspect for SecOps: CrowdSec can reconfigure itself at runtime based on the characteristics of HTTP requests. You are not limited to static YAML. You can shape WAF behaviour using logic and context.
However, this configuration-based method has two clear drawbacks once you reach the ScaleCommerce size:
- As the number of distinct configurations increases, the map becomes more difficult to read and reason about.
- Each configuration change requires editing code or a YAML file and then reloading CrowdSec, which makes the process cumbersome and risky at scale.
For a handful of tenants, this is fine. For hundreds, it becomes a maintenance and change management problem.
Take 2: A dynamic approach that treats config as data
Given ScaleCommerceâs context, reloading CrowdSec on each configuration change was not acceptable. Reloads are smooth and harmless at the Nginx level, but not at the CrowdSec level. To avoid this, we decided that the best way forward was to ship the configuration directly in the data processed by CrowdSec, inside the HTTP request itself.
This aligns well with ScaleCommerce, as they already utilize custom Lua code at the ingress (Nginx or OpenResty) level to modify incoming requests based on each customerâs configuration and customization.
The idea is simple:
- At the edge, Nginx looks at the request, applies tenant-specific logic, and decides which rules should be disabled.
- It writes this decision into a header, for example,
X-Custom-Disabled-Waf-Rules. - CrowdSec reads this header via
pre_evaland disables the listed rules for that specific request, both in-band and out of band.
Here is the CrowdSec scenario used in that approach (simplified):
name: smoxy/appsec-config
pre_eval:
- filter: IsInBand == true and req.Header.Get("X-Custom-Disabled-Waf-Rules") != ""
apply:
- |
let rules = split(req.Header.Get("X-Custom-Disabled-Waf-Rules"), ",");
map(
rules,
{RemoveInBandRuleByName(#)}
)
- filter: IsOutBand == true and req.Header.Get("X-Custom-Disabled-Waf-Rules") != ""
apply:
- |
let rules = split(req.Header.Get("X-Custom-Disabled-Waf-Rules"), ",");
map(
rules,
{RemoveOutBandRuleByName(#)}
)
on_match:
- filter: IsOutBand == true
apply:
- SendAlert()
This snippet again uses pre_eval hooks to customize, in real-time, which rules are enabled or disabled for a given request.
The difference is that the source of truth is no longer a large static map. Instead, the scenario reads the incoming X-Custom-Disabled-Waf-Rules header as the list of rules to disable. The upstream (nginx or any other component) can set this header as needed, depending on which tenant or environment is targeted.
On the Nginx side, it might look like this:
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
rewrite_by_lua_block {
ngx.req.clear_header("X-Custom-Disabled-Waf-Rules")
if ngx.var.uri:find("^/something/") then
ngx.req.set_header("X-Custom-Disabled-Waf-Rules", "crowdsecurity/vpatch-env-access")
end
}
In a production environment, the Lua code would not hardcode values. It would load data from an external configuration file, database, or a dedicated configuration service. The key idea remains the same: you inject headers that CrowdSec uses at runtime to reconfigure the WAF.
What this means for SecOps
From a SecOps perspective, this pattern has several clear benefits:
- You keep a single, shared CrowdSec WAF layer for all tenants, which simplifies operations and monitoring.
- Tenant-specific behaviour is controlled upstream, in your own configuration systems and code, instead of being buried in long YAML files.
- You avoid frequent CrowdSec reloads to handle âjust one more exceptionâ for a particular customer. Changes flow through as data in the request.
- You still benefit from CrowdSecâs detection capabilities and helper functions, including the ability to toggle rules in-band and out of band on a per-request basis.
In other words, you get proper multi tenancy without losing control. CrowdSec stays focused on security logic and decision-making, while your platform orchestrates who gets which rules, on which paths, and under which conditions.
For teams that run SecOps for many tenants, that mix of centralized protection with dynamic, per-request configuration is what makes the difference between a WAF that scales and a WAF that slowly becomes unmanageable.
As Thomas Lohner, ScaleCommerce Chief Time To First Byte Officer, puts it:
CrowdSec significantly lowered the number of attacks we get daily, sparing both resources and human time while sharply raising overall security.
This real-world impact shows how CrowdSecâs approach translates into fewer alerts, faster response, and more manageable security at scale.


.png&w=3840&q=75)
%20(29).jpg&w=3840&q=75)