- Published on
Ingress WAF - Applying ModSecurity/Coraza and the OWASP CRS
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- Why a WAF Is Needed at the Ingress
- ModSecurity and Coraza: What Differs and How
- Applying ModSecurity on ingress-nginx (Existing Environment)
- Applying Coraza on ingress-nginx (Recommended Path)
- Applying and Tuning the OWASP Core Rule Set
- Detection Mode vs Blocking Mode
- Logging and Alerting
- Performance Impact
- Comparison with Dedicated WAFs
- Limits and Cautions
- Adoption Checklist
- Conclusion
- References
Introduction
One Friday evening, a Slack message landed from the security team. "An external scanner detected more than ten thousand SQL injection attempts against our service domain. Are these actually being blocked?" I immediately opened the cluster Ingress configuration. And I realized something. We were terminating TLS and routing traffic, but we had no device whatsoever inspecting the incoming payloads at the application layer (L7).
Our architecture at the time was a common shape. A cloud load balancer sat in front of ingress-nginx, behind which dozens of microservices were wired up. Each service team did care about input validation in their own code, but there was no company-wide, consistent attack-blocking policy. Even if one team defended itself, an incident in another team meant a breach inside the same cluster, within the same trust boundary.
This post is about applying a WAF (Web Application Firewall) at the Ingress layer. I will walk through, from an operator's perspective, the difference between the legacy standard ModSecurity and its successor engine Coraza, ingress-nginx integration, the hands-on process of applying the OWASP Core Rule Set (CRS) and refining false positives, the operational strategy of detection versus blocking mode, logging and alerting, performance impact, and a comparison with dedicated WAFs.
There is one point worth flagging up front. As of 2026, the Kubernetes Ingress API is effectively frozen, with no new features being added, and the successor standard is the Gateway API. In addition, ingress-nginx has entered maintenance mode, and the ModSecurity support that was built into it is following a deprecation path in practice. So for a new build, you should consider a Coraza-based path, and over the medium to long term, a migration to the Gateway API as well. I will keep returning to this throughout the body.
Why a WAF Is Needed at the Ingress
A WAF is a firewall that inspects the OSI layer 7, that is, the content of the HTTP request itself. An ordinary network firewall or security group looks at IPs and ports (L3/L4), but a WAF looks into application data such as the request body, headers, query parameters, and cookies, and finds attack patterns.
In a Kubernetes environment, the Ingress is the single gateway through which external traffic enters the cluster. Placing a WAF right at this point brings the following benefits.
- Single point of enforcement: dozens of backend services no longer need to implement security logic individually; the gateway can enforce a consistent policy.
- Virtual patching: when you cannot fix the application code right away, you can temporarily block payloads targeting a known vulnerability (for example Log4Shell or a specific CVE) with a WAF rule.
- Defense in depth: there is no guarantee that an application's input validation is perfect. A WAF is a safety net layered on top of it.
- Visibility: you can record in logs what attacks are coming in and how many, which becomes the foundation for security monitoring and incident response.
That said, a WAF is not a silver bullet. Business-logic vulnerabilities (such as authorization bypass or IDOR) are hard to catch with pattern matching, encrypted traffic can only be inspected after TLS termination, and poor tuning can block legitimate traffic and cause an outage. I will revisit these limits later in the post.
Below is a traffic flow showing where a WAF sits.
Internet
|
v
+----------------------+
| Cloud LoadBalancer | (L3/L4: IP, port)
+----------------------+
|
v
+----------------------+
| ingress-nginx Pod |
| +----------------+ |
| | WAF engine | | (L7: HTTP payload inspection)
| | ModSec/Coraza | | <-- evaluate OWASP CRS rules
| +----------------+ |
+----------------------+
|
(on passing inspection)
v
+----------------------+
| Backend Service |
| (microservice) |
+----------------------+
ModSecurity and Coraza: What Differs and How
You should understand the WAF "engine" and the "rules" as separate things. The engine is the executor that evaluates rules, and the rules (for example the OWASP CRS) are the policy that defines what to block. The same CRS rules can run on either ModSecurity or Coraza.
ModSecurity (legacy)
ModSecurity is an open-source WAF engine that started back in 2002 and was the de facto standard for a long time. It began as an Apache module and later evolved into libmodsecurity (v3) supporting Nginx and IIS. ingress-nginx had this ModSecurity compiled in, and you could turn it on with a single annotation.
The trouble began when Trustwave, the core sponsor of ModSecurity, announced that it would end support for the ModSecurity project as of July 1, 2024. The project was subsequently handed over to OWASP, but from the ingress-nginx point of view, continuing to carry a heavy dependency written in C had become a burden. In fact, ingress-nginx maintainers marked the ModSecurity integration as deprecated and made the direction toward future removal explicit.
Coraza (successor)
Coraza is a WAF engine written in Go and stewarded by OWASP. Its key characteristic is that it is largely compatible with ModSecurity's rule syntax (SecRule). In other words, you can take the SecLang rules and OWASP CRS you already used and run them almost unchanged. Being written in Go, it has high memory safety, and it is designed to be embedded into various proxies such as Envoy and Caddy, and in the form of coraza-proxy-wasm.
As of 2026, if you are newly introducing an L7 WAF in a cloud-native environment, the Coraza family is effectively the standard direction. In particular, on Envoy-based data planes (many Gateway API implementations use Envoy), the pattern of slotting a WAF in as a coraza-proxy-wasm filter has taken hold.
Comparison Table
| Item | ModSecurity (libmodsecurity v3) | Coraza |
|---|---|---|
| Language | C / C++ | Go |
| Released | 2002 (v3 in 2017) | 2021 |
| Rule syntax | SecLang (SecRule) | SecLang compatible |
| OWASP CRS | supported | supported |
| ingress-nginx integration | built in (to be deprecated) | external/WASM or Gateway API path |
| Envoy embedding | limited | native via proxy-wasm |
| Maintenance status | moved to OWASP, legacy | active |
| Memory safety | manual management | GC-based |
Remember only the essentials. The rules (CRS) are reused identically, and only the engine swaps from ModSecurity to Coraza.
Applying ModSecurity on ingress-nginx (Existing Environment)
Let me first cover an existing environment that already uses ingress-nginx with built-in ModSecurity. For a new build, please look at the Coraza path in the next section.
In ingress-nginx, ModSecurity is controlled at two layers: the global configuration in a ConfigMap and Ingress annotations. First, the controller's global ConfigMap.
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
enable-modsecurity: "true"
enable-owasp-modsecurity-crs: "true"
# Global ModSecurity configuration snippet
modsecurity-snippet: |
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecAuditEngine RelevantOnly
SecAuditLogParts ABIJDEFHZ
SecAuditLog /var/log/modsec/audit.log
SecAuditLogType Serial
In the configuration above, enable-modsecurity turns on the engine itself, and enable-owasp-modsecurity-crs loads the OWASP CRS bundled into the controller image. SecRuleEngine DetectionOnly is extremely important when you first introduce a WAF. It is a mode that does not actually block, only detects, while accumulating logs.
The following is an annotation example that applies ModSecurity to a specific Ingress only, or overrides detailed settings.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-ingress
namespace: shop
annotations:
nginx.ingress.kubernetes.io/enable-modsecurity: "true"
nginx.ingress.kubernetes.io/enable-owasp-core-rules: "true"
nginx.ingress.kubernetes.io/modsecurity-snippet: |
SecRuleEngine On
SecRequestBodyLimit 13107200
SecRule REQUEST_HEADERS:User-Agent "@contains badscanner" "id:1001,phase:1,deny,status:403,log,msg:'Blocked scanner'"
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop-svc
port:
number: 80
Here, the annotation-level SecRuleEngine On has the effect of turning on blocking mode for that Ingress only. In other words, you can keep the global setting at DetectionOnly and promote only verified services to blocking mode one at a time, a gradual rollout.
Applying Coraza on ingress-nginx (Recommended Path)
Since the built-in ModSecurity is on a deprecation path, slotting Coraza in from the outside is the modern alternative. There are two representative patterns.
First, calling an OpenResty-based Coraza Lua integration (coraza-nginx) through ingress-nginx's server-snippet or http-snippet. Second, changing the data plane itself to be Envoy-based (for example a Gateway API implementation) and attaching a coraza-proxy-wasm filter.
Below is a conceptual example of configuring a coraza-proxy-wasm filter on an Envoy-based data plane. You apply it through an EnvoyFilter or the extension mechanism specific to each gateway implementation.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: coraza-waf
namespace: istio-system
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
name: coraza-filter
vm_config:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/coraza-proxy-wasm.wasm
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |
{
"directives_map": {
"default": [
"SecRuleEngine On",
"Include @owasp_crs/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"
]
},
"rules": ["default"]
}
Note that Coraza's directive syntax is identical to ModSecurity. SecRuleEngine On and the Include directive are all the same. On migration you barely need to touch the rules themselves.
There is also a pattern that runs Coraza as a standalone daemon and delegates inspection via nginx's auth_request or a sidecar, but considering operational complexity and latency, the WASM approach of embedding directly into the data plane is cleaner.
Applying and Tuning the OWASP Core Rule Set
Once the engine is on, the key question becomes which rules to inspect with. The OWASP CRS is the most widely used general-purpose rule set, providing rules that correspond to the OWASP Top 10, such as SQL injection, XSS, RCE, LFI/RFI, and protocol violations, bundled by category.
Anomaly Scoring
Understanding how the CRS works is the starting point for tuning. The CRS operates by default on an anomaly-scoring model. Every time a rule matches, it adds a score to the request, and when that accumulated score exceeds a threshold, it blocks. It is not the case that one matched rule immediately blocks; rather, suspicious signals must accumulate before a block occurs.
Rules are assigned a score according to severity.
| Severity | Default score |
|---|---|
| CRITICAL | 5 |
| ERROR | 4 |
| WARNING | 3 |
| NOTICE | 2 |
The default inbound threshold is usually 5. So even a single CRITICAL rule match reaches the threshold. This threshold and the scoring scheme are adjusted in the CRS configuration file.
# crs-setup.conf excerpt (conceptual example)
SecAction "id:900110,phase:1,pass,nolog,\
setvar:tx.inbound_anomaly_score_threshold=5,\
setvar:tx.outbound_anomaly_score_threshold=4"
# Paranoia Level setting
SecAction "id:900000,phase:1,pass,nolog,\
setvar:tx.paranoia_level=1"
Paranoia Level
The CRS has "paranoia levels" from PL1 to PL4. The higher the level, the more aggressive and strict the activated rules, which raises the detection rate, but false positives surge accordingly.
| Level | Character | Recommended use |
|---|---|---|
| PL1 | baseline, almost no false positives | starting point for most production |
| PL2 | somewhat strict | security-sensitive services, after tuning |
| PL3 | very strict | high-security such as finance, sufficient tuning required |
| PL4 | extremely strict | special environments, very many false positives |
The practical recommendation is clear. Start at PL1, accumulate logs in DetectionOnly long enough, clean up false positives, then raise to blocking mode, and increase the PL slowly if needed. If you turn on blocking mode at PL3 from the start, nine times out of ten a legitimate service gets blocked and you cause an outage.
Managing False Positives
When you introduce the CRS, you will inevitably meet false positives. For example, if a SQL-like string ends up in a JSON body, or a rich-text editor transmits HTML tags as-is, it trips the XSS rules. To avoid blocking legitimate traffic, you must write exception rules.
The standard way to handle false positives is to disable a specific rule ID only for a specific path or parameter.
# Disable a specific rule only on a specific path
SecRule REQUEST_URI "@beginsWith /api/articles" \
"id:10001,phase:1,pass,nolog,\
ctl:ruleRemoveById=942100"
# Disable a specific rule only for a specific parameter
SecRule REQUEST_URI "@beginsWith /admin/editor" \
"id:10002,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941100;ARGS:content"
Here, ruleRemoveById turns off the entire rule, while ruleRemoveTargetById excludes rule inspection only for a specific input field (here the content parameter). When possible, narrowing the exclusion to the target is safer than turning off the whole rule. Blindly disabling all rule IDs effectively neutralizes the WAF.
The flow for finding false positives is as follows.
[1] Operate in DetectionOnly mode
|
v
[2] Collect requests that would have been blocked from the audit log
|
v
[3] Identify rule IDs that matched on legitimate traffic
|
v
[4] Write path/parameter-level exception rules
|
v
[5] When false positives drop enough, promote to blocking (On) mode
Detection Mode vs Blocking Mode
One of the most important decisions in WAF operation is "block now, or just observe?"
| Mode | SecRuleEngine value | Behavior | Use |
|---|---|---|---|
| Disabled | Off | does not evaluate rules | emergency disable |
| Detection | DetectionOnly | logs matches only, passes through | early introduction, tuning phase |
| Blocking | On | blocks when threshold exceeded | well-verified production |
The principle is to always start with DetectionOnly. If you turn on blocking mode on a new service right away, you do not know that service's legitimate traffic patterns, so the risk of an outage from false positives is high. It is safer to run in detection mode for at least several days to several weeks, analyze the logs, clean up false-positive exceptions, and then promote services to blocking mode one at a time.
In an emergency, if you judge that the WAF is blocking legitimate traffic, it is good to document a rollback procedure in advance so you can revert to DetectionOnly immediately. Since it takes effect instantly with a single annotation change, fast mitigation is possible.
Logging and Alerting
Half of a WAF's value comes from visibility. Without a record of what was blocked and what was observed, you can neither refine the blocking policy nor trace an incident.
ModSecurity/Coraza produce audit logs. The SecAuditEngine RelevantOnly seen above means recording only requests whose anomaly score exceeded the threshold or that were explicitly marked for logging. Recording every request (On) makes the log volume explode, so RelevantOnly is the norm in operations.
If you emit the audit log in JSON form, downstream collection and analysis become easier.
SecAuditLogFormat JSON
SecAuditLogType Serial
SecAuditLog /var/log/modsec/audit.log
Logs emitted this way are scraped by a sidecar or node-level log collector (Fluent Bit, Vector, and so on) and sent to a central log system (Loki, Elasticsearch, OpenSearch). From there, a common setup is to build dashboards by matched rule ID, client IP, and attack category, and to put alerts on a particular threshold (for example a surge in blocked counts per minute).
A caution in alert design is that internet-exposed services constantly receive scanner traffic, so "one alert per block" quickly becomes noise. You should put alerts on meaningful signals such as a trend (a surge versus normal), a concentrated attack from a particular source, or a spike of false positives right after switching to blocking mode.
Performance Impact
A WAF is not free. Since it evaluates the body and headers of every request against hundreds of regex-based rules, there is a cost in CPU and latency.
- CPU: when running the full CRS rule set at PL1, the CPU usage of the ingress controller increases noticeably. Rule evaluation is a CPU-bound task.
- Latency: the latency added per request is usually on the order of a few milliseconds, but it can grow larger when inspecting a large request body or when the paranoia level is high.
- Request body buffering: since the WAF buffers the request body for body inspection, you must set
SecRequestBodyLimitand the body-inspection policy carefully on large-upload paths.
The practical recommendations are as follows. First, after turning on the WAF, always measure changes in latency and throughput with a load test. Second, recalculate the ingress controller's resource requests/limits and replica count. Third, review separate handling (for example excluding body inspection only on that path) for paths where body inspection is unnecessary or risky, such as large file uploads.
# Example of limiting body inspection on a large-upload path (annotation snippet)
nginx.ingress.kubernetes.io/modsecurity-snippet: |
SecRule REQUEST_URI "@beginsWith /upload" \
"id:20001,phase:1,pass,nolog,ctl:requestBodyAccess=Off"
Comparison with Dedicated WAFs
A built-in Ingress WAF is not the right answer for every situation. You should weigh the pros and cons against managed cloud WAFs (AWS WAF, Cloud Armor, Azure Front Door WAF, and so on) or appliance/CDN-style WAFs (Cloudflare, Akamai, and so on).
| Item | Ingress built-in (ModSec/Coraza) | Managed cloud WAF | CDN/appliance style |
|---|---|---|---|
| Location | inside the cluster | cloud edge/in front of LB | global edge |
| Cost model | compute resources only | per request/rule | per traffic/plan |
| Operating party | self-operated | managed | managed |
| DDoS defense | none (separate needed) | partially included | strong |
| Rule customization | very flexible (SecLang) | console/limited | varies by provider |
| Latency location | near the backend | edge | edge |
| Cluster-internal traffic | can be protected | external traffic only | external traffic only |
The big picture is this. If large-scale DDoS and global edge defense matter, a CDN/cloud WAF has the advantage; if you want fine-grained rule control, want to inspect even cluster-internal east-west traffic, or want to avoid vendor lock-in, an Ingress built-in (Coraza) is favorable. In practice, a layered configuration overlapping both is common. The edge filters out mass attacks and bots, while the Ingress applies precise application rules.
Limits and Cautions
These are the limits you must be aware of when introducing a WAF.
- Cannot block business-logic attacks: logic vulnerabilities such as authorization bypass, IDOR, or price manipulation are normally formatted requests, so they are not caught by pattern matching. A WAF does not replace authentication/authorization design.
- Bypass techniques exist: there are endless attempts to bypass signatures with encoding, chunked splitting, parameter pollution, and so on. The CRS responds with normalization transforms, but it is not perfect.
- TLS termination dependency: encrypted traffic can only be inspected after decryption. The placement of termination matters.
- Outage from false positives: bad tuning is itself a service outage. Switching to blocking mode must be cautious.
- Performance burden: as covered above, there is a CPU and latency cost.
- Maintenance: both the rules (CRS) and the engine must be updated continuously. A neglected WAF fails to block new attacks.
And there is an operational context worth stressing again. As of 2026, ingress-nginx is in maintenance mode and the built-in ModSecurity is headed toward deprecation. If you are building anew, you should design the Coraza-based path together with a transition to the Gateway API to be safe long-term. Since many Gateway API implementations use Envoy as their data plane, this dovetails naturally with the picture of attaching coraza-proxy-wasm.
Adoption Checklist
Here are the items to check when introducing an Ingress WAF in practice.
- Engine choice: Coraza for new builds, a migration roadmap for existing ModSecurity
- Did you deploy in DetectionOnly mode first?
- Did you start the OWASP CRS at PL1?
- Are you collecting audit logs into a central log system?
- Do you have an analysis flow to identify false positives (path/parameter-level exceptions)?
- Is promotion to blocking mode a gradual, per-service rollout?
- Is an emergency rollback procedure (immediate revert to DetectionOnly) documented?
- Did you measure performance impact with a load test?
- Did you recalculate the ingress controller resources/replicas?
- Did you define body-inspection exception paths such as large uploads?
- Are alerts placed on meaningful trends rather than single events?
- Did you set an update cadence for the CRS and the engine?
- Did you define the division of roles with a dedicated WAF (edge)?
- Did you review how the WAF will be applied when transitioning to the Gateway API?
Conclusion
Going back to that Friday evening when I first got the Slack message, what we actually needed was not a single magical blocking device but a procedure. Placing a WAF at the Ingress is certainly a powerful line of defense, but it is not something that ends the moment you turn it on; it is operations that begin from there.
To sum up the essentials: the engine is on a path from ModSecurity to Coraza, the rules are the OWASP CRS applied carefully starting at PL1, and operations start in DetectionOnly, clean up false positives, then promote gradually to blocking. And a WAF is just one layer of defense in depth; it does not replace application security and authentication/authorization design.
Finally, amid the 2026 trend where ingress-nginx is entering maintenance mode and the Gateway API is becoming the standard, I recommend designing your WAF strategy together with that transition. The engine and data plane may change, but the operational principle of "start with detection, move carefully to blocking" does not.
References
- Kubernetes Ingress official docs: https://kubernetes.io/docs/concepts/services-networking/ingress/
- ingress-nginx official docs: https://kubernetes.github.io/ingress-nginx/
- ingress-nginx ModSecurity user guide: https://kubernetes.github.io/ingress-nginx/user-guide/third-party-addons/modsecurity/
- Coraza WAF official site: https://coraza.io/
- Coraza GitHub repository: https://github.com/corazawaf/coraza
- coraza-proxy-wasm GitHub: https://github.com/corazawaf/coraza-proxy-wasm
- OWASP ModSecurity Core Rule Set project: https://owasp.org/www-project-modsecurity-core-rule-set/
- OWASP CRS official site: https://coreruleset.org/
- OWASP CRS docs (paranoia levels): https://coreruleset.org/docs/concepts/paranoia_levels/
- Gateway API official docs: https://gateway-api.sigs.k8s.io/
- Traefik official docs: https://doc.traefik.io/traefik/