Getting Useful ModSecurity Logs in Liferay PaaS: Lessons from the Field

Liferay's documentation does a good job of explaining how to turn ModSecurity on, how to provide your own configuration, and how to send the audit log to the Liferay Cloud Console.
But, as I recently discovered while working with a client, there is a pretty big gap between:
ModSecurity is enabled and I can see logs.
and:
I'm getting the ModSecurity information I actually need, reliably, without overwhelming the logging pipeline.
We spent quite a bit of time working through that gap.
This post isn't intended to replace the official documentation. Think of it as the next chapter: some practical configuration guidance that became apparent only after working through a real-world implementation.
Start with the Liferay Documentation
If you haven't already enabled ModSecurity, start with Liferay's official Web Application Firewall documentation.
In webserver/LCP.json, enable ModSecurity using:
{
"env": {
"LCP_WEBSERVER_MODSECURITY": "DetectionOnly"
}
}
Liferay supports three values:
Off— ModSecurity does not process rules.DetectionOnly— rules are evaluated, but disruptive actions aren't executed.On— rules are evaluated and enforcement actions can be executed.
Liferay recommends starting with DetectionOnly, testing and tuning the rules, and moving to On only after you're comfortable that legitimate traffic won't be disrupted.
That's good advice. Don't skip that step.
Also remember that if you provide your own:
webserver/configs/[ENV]/modsec/modsecurity.conf
you're not augmenting the default Liferay configuration.
You're replacing it.
Your file needs to provide all of the necessary ModSecurity configuration. That detail becomes important very quickly.
Local Log Files and Containers Don't Mix Particularly Well
The default audit-log configuration typically writes to something like:
/var/log/modsec_audit.log
That's perfectly reasonable on a traditional server.
The server starts, it runs for months or years, and logs accumulate on its filesystem until something rotates or archives them.
A PaaS container doesn't work that way.
Containers can be replaced, restarted, rescheduled, or scaled. A log file living only inside one webserver container belongs to the lifetime of that container.
So if the goal is to retain ModSecurity output independently of a particular container, writing only to a local file isn't a very useful long-term strategy.
Liferay's documentation already provides the key change:
SecAuditLog /dev/stdout
This sends the audit output to standard output, allowing it to appear with the webserver service logs in the Liferay Cloud Console making it part of the platform logging stream, but more importantly it means you get ModSec logs from all containers (even those that autoscale up/down) and can download/retain them for as long as you require.
But this is also where things get interesting.
/dev/stdout Doesn't Mean "Dump Everything"
During our troubleshooting, we reached a configuration that looked approximately like this:
SecAuditEngine On
SecAuditLogParts ABIJDEFHZK
SecAuditLogType Serial
SecAuditLog /dev/stdout
SecAuditLogFormat JSON
At first glance, that seems reasonable.
Turn auditing on.
Capture lots of information.
Use JSON because structured logging is cool.
Send it all to stdout.
What could possibly go wrong?
Quite a lot, actually.
SecAuditEngine On Can Generate a Lot of Data
SecAuditEngine controls which transactions receive an audit record.
The available values are:
SecAuditEngine Off
SecAuditEngine On
SecAuditEngine RelevantOnly
On means audit every transaction.
RelevantOnly means audit transactions that generated a warning or error, or whose response status matches SecAuditLogRelevantStatus.
For a security audit log, logging every ordinary request usually isn't what you're looking for.
A normal page can involve dozens or hundreds of requests. Add images, JavaScript, CSS, APIs, probes, monitoring requests, and normal application traffic, and On can generate an enormous amount of audit data.
Our working configuration changed this to:
SecAuditEngine RelevantOnly
For example:
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
For a WAF, that's much closer to what you normally want:
Tell me about the interesting transactions. Don't create another copy of my entire access log.
Then There Was the E
The second major finding was buried inside this innocent-looking setting:
SecAuditLogParts ABIJDEFHZK
Those letters aren't a format.
They tell ModSecurity which parts of the HTTP transaction to include in the audit record.
And one of those letters can make a very big difference:
E
E tells ModSecurity to include the response body.
Think about what that means.
If the response is a 300 KB HTML page, you can end up putting that response into the audit record.
Now combine:
SecAuditEngine On
with:
SecAuditLogParts ...E...
and:
SecAuditLogFormat JSON
and:
SecAuditLog /dev/stdout
You've potentially asked ModSecurity to serialize large response bodies into JSON for every request and push all of it through the container logging pipeline.
That turned out to be a very important combination in our troubleshooting.
The change that ultimately helped was removing E:
- SecAuditLogParts ABIJDEFHZK
+ SecAuditLogParts ABIJDFHZK
Same auditing capability.
Same rules.
Same request processing.
But the response body is no longer being stuffed into the audit output.
And that's an important distinction:
Removing E does not disable response-body inspection.
SecResponseBodyAccess controls whether ModSecurity examines response bodies.
SecAuditLogParts controls what gets recorded in the audit log.
Those are two different decisions.
So What Do All Those Letters Mean?
Rather than copying a value from a sample configuration, you should understand what you're asking ModSecurity to retain.
A — Audit Log Header
Contains the audit entry header. This part is mandatory.
B — Request Headers
Contains the HTTP request headers. This is normally very useful when investigating why a rule matched.
C — Request Body
Contains the request body when one exists and request-body inspection is enabled.
This can be useful, but request bodies can also contain substantial amounts of data or sensitive information.
D — Reserved
Reserved for intermediary response headers. It is not currently implemented in ModSecurity 3.x.
E — Response Body
Contains the response body when response-body inspection is enabled.
This was the important one in our investigation.
Response bodies can be large, and including them in centralized audit output can dramatically increase both individual record size and overall log volume.
Unless you have a specific business or forensic requirement for storing response bodies, I would leave E out.
F — Final Response Headers
Contains the final response headers. Useful without carrying the potentially large cost of storing the response body.
G — Reserved
Reserved for the actual response body and not currently implemented.
H — Audit Log Trailer
Contains the audit-log trailer and additional transaction information.
I — Not Implemented in ModSecurity 3.x
Older ModSecurity documentation describes I as a reduced multipart request body that could be used instead of C.
The ModSecurity 3.x reference lists it as not implemented.
J — Uploaded File Information
Contains information about files uploaded using multipart/form-data.
K — Not Implemented in ModSecurity 3.x
In older ModSecurity versions, K represented matched-rule information. In ModSecurity 3.x it is listed as not implemented.
Z — End of Audit Entry
Marks the end of the audit entry. Like A, this is mandatory.
Choose the Parts You Actually Need
The ModSecurity 3.x documented default is:
ABCFHZ
The configuration we were working with contained:
ABIJDEFHZK
and the critical operational change was:
ABIJDFHZK
removing E.
If I were designing a brand-new configuration today, though, I wouldn't start by asking:
What long sequence of letters should I copy?
I'd ask:
What information does the security team actually need?
Then select the parts accordingly.
In particular, make deliberate decisions about:
C - request body
E - response body
J - uploaded file information
Those can materially affect log size and potentially the sensitivity of the information you're retaining.
Native or JSON?
This is another place where two different settings are sometimes conflated.
The audit parts are selected with:
SecAuditLogParts ...
The audit format is selected separately:
SecAuditLogFormat Native
or:
SecAuditLogFormat JSON
The default ModSecurity format is Native. JSON is also supported.
JSON can be very useful.
If you're feeding logs into a SIEM, writing automated analysis, or extracting rule IDs, anomaly scores, messages, and transaction fields programmatically, JSON is much easier to work with.
But JSON isn't required to make ModSecurity logging work.
My recommendation is to make this a business/use-case decision, not a technical checkbox.
Use Native when:
- humans are primarily reading the logs;
- you want the familiar ModSecurity audit representation;
- you don't need structured downstream processing.
Consider JSON when:
- you're feeding the records into security tooling;
- you're building automated filtering or analytics;
- you need structured fields rather than text parsing.
But regardless of the format, control what you're putting into each record.
Changing from Native to JSON doesn't make a huge response body suddenly small.
Don't Forget the Other ModSecurity Log
One thing that caused some confusion during our work was that ModSecurity information can show up through more than one logging path.
The detailed transaction-oriented information goes to the audit log.
But the familiar messages that look something like:
ModSecurity: Warning. Matched ...
are emitted through Nginx's error logging path.
So if those are the messages you care about seeing in the Liferay Console, make sure Nginx is also writing its error log to stdout:
error_log /dev/stdout warn;
Nginx supports these logging levels:
debug
info
notice
warn
error
crit
alert
emerg
Selecting a level includes that level and all more-severe levels.
In our case, warn was the right normal operating level.
During troubleshooting you might temporarily use notice, info, or even debug.
But don't leave highly verbose logging enabled just because more data feels safer.
Sometimes more logs just means more hay around the needle.
Be Careful with SecDebugLogLevel 9
Along the same lines, we used very verbose ModSecurity debug logging while troubleshooting.
That's appropriate when you're trying to understand why something isn't behaving correctly.
It isn't something I want running indefinitely.
For normal operation we brought the level back down:
SecDebugLogLevel 3
rather than:
SecDebugLogLevel 9
Level 9 can be useful when troubleshooting, but once you understand the problem, turn the noise back down.
A Practical PaaS Configuration
Pulling the important pieces together, the configuration pattern I'd start from looks more like this:
SecRuleEngine ${LCP_WEBSERVER_MODSECURITY}
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDFHZK
SecAuditLogType Serial
SecAuditLog /dev/stdout
SecDebugLogLevel 3
And in Nginx:
error_log /dev/stdout warn;
Then make an explicit decision about the audit format.
For Native:
SecAuditLogFormat Native
or simply rely on the native default.
For structured output:
SecAuditLogFormat JSON
And again, review SecAuditLogParts rather than simply copying my string.
The important operational characteristics are:
SecAuditEngine RelevantOnly
rather than On, and:
E is not included in SecAuditLogParts
unless you have a deliberate reason for retaining response bodies.
What About Access Logs?
You may also want the normal Nginx request record in the same centralized stream:
access_log /dev/stdout main;
That can be helpful when correlating a ModSecurity event back to the corresponding request.
Whether you want all access-log traffic in the Console is a separate operational decision.
Again, resist the temptation to turn on everything simply because you can.
And Then Download the Logs
Getting the logs into the Liferay Console solves the container-lifetime problem, but the Console shouldn't necessarily be your organization's permanent audit archive, especially since logs are only kept for 30 days.
The Liferay command-line tool can retrieve service logs:
lcp log -p <project>-<environment> -s webserver
and constrain the export by time:
lcp log \
-p <project>-<environment> \
-s webserver \
--since "<start_time>" \
--until "<end_time>" \
>> webserver-logs.txt
For an organization that needs longer-term security retention, I'd automate this.
For example, create a scheduled job to download the logs once a week, filter out the log messages not related to ModSec, and then keep those files for as long as you need.
If you're using JSON audit output, that processing gets particularly easy because you can parse fields instead of relying entirely on string matching.
The Bigger Lesson
The biggest lesson from this exercise wasn't a magic ModSecurity directive.
It was that logging in a container platform needs to be treated as a pipeline, not as a file.
There's the application generating the information.
There's ModSecurity deciding what should be audited.
There's Nginx deciding what goes to its error and access logs.
There's stdout.
There's the container runtime.
There's the platform logging infrastructure.
And finally there's the Console where a human sees the result.
You can have a perfectly valid ModSecurity configuration and still create a terrible logging configuration.
In our case, these were all individually reasonable choices:
- Audit everything.
- Include the response.
- Use JSON.
- Send it to stdout.
Together, they weren't reasonable.
The final approach was much simpler:
- Audit the relevant transactions.
- Don't include giant response bodies unless you actually need them.
- Send the useful output to stdout.
- Keep normal logging at a sensible verbosity.
- Export what you need for long-term retention.
That's a configuration you can live with.
And, more importantly, it's one the logging pipeline can live with too.

