Ship web app security events to a SIEM with JSON over syslog/HTTP
For developers who need their web application security events in a SIEM without guessing what to log. This shows a concrete event schema, app and nginx examples, rsyslog forwarding, HTTP fallback, and end-to-end verification so alerts can key off consistent fields.
TL;DR — You need two things for useful SIEM ingestion: a small, stable security-event schema and a transport that does not block your app. Log authentication, authorization, session, admin, rate-limit, input-validation, and security-control events as one JSON object per event, write locally first, then forward with rsyslog over TCP/TLS or HTTPS. The most common fix is to stop shipping raw access logs and instead emit explicit app-level security events with a correlation ID and outcome field. Reading time: ~5 min
Goal
When you are done, your web application emits structured security events to a local file or stdout, a forwarder ships them to your SIEM over TCP/TLS or HTTPS, and you can prove end-to-end delivery by generating a failed login and finding one JSON event in the SIEM with the expected fields.
Prerequisites
- SSH or shell access to the app host or container runtime where the web app runs
- Permission to change app logging config and restart the service
- A SIEM ingestion endpoint and auth details: either
host:portfor syslog TCP/TLS or an HTTPS collector URL plus token curl >= 8— check withcurl --versionjq >= 1.6— check withjq --versionopenssl >= 3if using TLS syslog — check withopenssl versionrsyslog >= 8.2302on Linux hosts if you want local buffering and forwarding — check withrsyslogd -v- If using nginx, access to its config and
nginx -t - Your app’s service name for restart commands, for example
myapp.service
Steps
Step 1: Define the exact event schema your app will emit
Create a single JSON shape and do not vary field names by endpoint.
{
"ts": "2026-10-30T12:34:56.789Z",
"event_type": "auth.login",
"action": "login",
"outcome": "failure",
"severity": "medium",
"user": {
"id": "12345",
"email": "alice@example.com"
},
"source": {
"ip": "203.0.113.10",
"forwarded_ip": "198.51.100.24",
"user_agent": "Mozilla/5.0"
},
"http": {
"method": "POST",
"path": "/login",
"status": 401,
"request_id": "7f4d4c2f8d3a4b2e"
},
"session": {
"id": "a4f0...",
"mfa": false
},
"security": {
"reason": "bad_password",
"policy": "local_auth",
"risk_score": 35
},
"service": {
"name": "billing-api",
"env": "prod",
"version": "2.18.0"
}
}
What you should see when this step succeeds: one agreed schema in your repo or runbook, with required fields ts, event_type, outcome, source.ip, and http.request_id.
Step 2: Emit only the security events worth sending
Log these event types from application code; skip full request/response bodies and secrets.
| event_type | When to emit | Required extras |
|---|---|---|
auth.login | every login attempt | outcome, security.reason on failure |
auth.logout | logout | session.id |
auth.mfa | challenge, success, failure | security.reason |
auth.password_reset | request, token verify, completion | outcome |
auth.account_lock | lock/unlock | security.reason |
authz.denied | permission denied / 403 | resource, action |
session.created / session.revoked | session lifecycle | session.id |
admin.change | user/role/config changes | target, before/after summary |
rate_limit.triggered | 429 or WAF/app throttling | security.policy |
input.validation_failed | blocked payloads, invalid signatures | security.reason |
security.control | CSP violation, CSRF failure, webhook signature failure | security.reason |
What you should see when this step succeeds: your code paths map to explicit event types instead of relying on generic access logs.
Step 3: Add app logging code that writes one JSON line per event
Node.js example using stdout or a file; adapt the same fields in other languages.
npm install pino@9
import pino from 'pino';
import crypto from 'node:crypto';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function logSecurityEvent(req, event) {
logger.info({
ts: new Date().toISOString(),
event_type: event.event_type,
action: event.action,
outcome: event.outcome,
severity: event.severity || 'medium',
user: event.user || {},
source: {
ip: req.socket.remoteAddress,
forwarded_ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim(),
user_agent: req.headers['user-agent']
},
http: {
method: req.method,
path: req.originalUrl || req.url,
status: event.status,
request_id: req.headers['x-request-id'] || crypto.randomUUID()
},
session: event.session || {},
security: event.security || {},
service: {
name: process.env.SERVICE_NAME || 'billing-api',
env: process.env.NODE_ENV || 'prod',
version: process.env.APP_VERSION || 'unknown'
}
}, 'security_event');
}
Example use in a failed login handler:
logSecurityEvent(req, {
event_type: 'auth.login',
action: 'login',
outcome: 'failure',
status: 401,
security: { reason: 'bad_password', policy: 'local_auth', risk_score: 35 },
user: { email: req.body.email }
});
What you should see when this step succeeds: one compact JSON object per security event in stdout or your app log, not multiline stack traces.
Step 4: If you use nginx, log security-relevant edge events separately
Add a JSON access log for 401/403/429 and suspicious requests. This complements app events; it does not replace them.
⚠️ Reloading nginx with a broken config causes downtime. Run
nginx -tbeforesystemctl reload nginx.
log_format security_json escape=json '{"ts":"$time_iso8601","event_type":"edge.request","outcome":"$status","source":{"ip":"$remote_addr","forwarded_ip":"$http_x_forwarded_for","user_agent":"$http_user_agent"},"http":{"method":"$request_method","path":"$uri","status":$status,"request_id":"$request_id"},"service":{"name":"nginx","env":"prod"}}';
map $status $security_loggable {
default 0;
401 1;
403 1;
429 1;
}
access_log /var/log/nginx/security.log security_json if=$security_loggable;
sudo nginx -t && sudo systemctl reload nginx
What you should see when this step succeeds: syntax is ok and test is successful, then /var/log/nginx/security.log starts receiving JSON lines for 401/403/429.
Step 5: Forward events with rsyslog over TCP/TLS with disk buffering
Create a dedicated rsyslog config that tails your JSON file and forwards it. Replace siem.example.net and the CA path.
sudo tee /etc/rsyslog.d/60-security-events.conf >/dev/null <<'EOF'
module(load="imfile")
module(load="omfwd")
template(name="RawJsonLine" type="string" string="%msg%\n")
input(type="imfile"
File="/var/log/myapp/security.log"
Tag="myapp-security"
Facility="local6"
Severity="info"
PersistStateInterval="100"
addMetadata="off")
action(type="omfwd"
Target="siem.example.net"
Port="6514"
Protocol="tcp"
StreamDriver="gtls"
StreamDriverMode="1"
StreamDriverAuthMode="x509/name"
StreamDriverPermittedPeers="siem.example.net"
StreamDriverCAFile="/etc/ssl/certs/ca-certificates.crt"
Template="RawJsonLine"
action.resumeRetryCount="-1"
queue.type="LinkedList"
queue.filename="security_fwd"
queue.maxdiskspace="1g"
queue.saveonshutdown="on")
EOF
sudo rsyslogd -N1
sudo systemctl restart rsyslog
What you should see when this step succeeds: rsyslogd: End of config validation run. Bye. and systemctl status rsyslog shows active (running).
Step 6: If your SIEM only accepts HTTPS, post JSON with a sidecar or agent
For quick validation, send one event directly with curl. Use this to test the collector before wiring an agent.
curl -sS -o /tmp/siem-response.txt -w '%{http_code}\n' \
-X POST 'https://siem.example.net/collector/events' \
-H 'Authorization: Bearer REPLACE_WITH_TOKEN' \
-H 'Content-Type: application/json' \
--data-binary @<(jq -nc '{ts: now|todateiso8601, event_type:"auth.login", action:"login", outcome:"failure", severity:"medium", user:{email:"alice@example.com"}, source:{ip:"203.0.113.10", user_agent:"curl/8"}, http:{method:"POST", path:"/login", status:401, request_id:"manual-test-1"}, security:{reason:"bad_password"}, service:{name:"billing-api", env:"prod", version:"2.18.0"}}')
cat /tmp/siem-response.txt
Typical success is 200, 201, or 202 depending on the collector.
What you should see when this step succeeds: the command prints 200, 201, or 202, and the response body does not contain invalid token, schema error, or payload too large.
Verify it works
Generate one known event and confirm it traverses app -> forwarder -> SIEM.
View as text
[Browser] ---- 1. POST /login bad password ----> [Web App] [Web App] ---- 2. JSON security event ----> [Local Log] [rsyslog] ---- 3. TCP/TLS or HTTPS ----> [SIEM]
- Trigger a failed login against a non-production test account.
curl -i -X POST 'https://app.example.com/login' \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: kb-test-401-1' \
--data '{"email":"alice@example.com","password":"wrong-password"}'
Expected response shape:
HTTP/2 401
content-type: application/json
x-request-id: kb-test-401-1
- Confirm the app wrote the event locally.
grep 'kb-test-401-1' /var/log/myapp/security.log | tail -n 1 | jq .
Expected fields include:
{
"event_type": "auth.login",
"outcome": "failure",
"http": { "request_id": "kb-test-401-1", "status": 401 },
"security": { "reason": "bad_password" }
}
- Confirm rsyslog can reach the SIEM listener if using syslog TLS.
openssl s_client -connect siem.example.net:6514 -servername siem.example.net -brief </dev/null
Expected output shape:
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Verification: OK
- Search the SIEM for
http.request_id="kb-test-401-1"orevent_type="auth.login" AND outcome="failure"and verify exactly one event arrives within your normal ingestion delay.
Common pitfalls
Logging passwords, tokens, or full request bodies
Mistake: serializing req.body, cookies, or auth headers into the event.
Symptom: secrets appear in local logs and the SIEM; security review blocks rollout.
Fix: log only allowlisted fields like user.email, security.reason, http.path, and explicitly drop authorization, cookie, password, token, and request bodies.
Trusting X-Forwarded-For from the internet
Mistake: storing source.ip directly from X-Forwarded-For when the app is not behind a trusted proxy.
Symptom: attackers spoof source IPs; detections for brute force and geo anomalies become useless.
Fix: use the socket remote address unless your reverse proxy is trusted and configured to sanitize forwarding headers.
Shipping only nginx access logs
Mistake: forwarding generic access logs and expecting the SIEM to infer auth failures, admin changes, and session revocations.
Symptom: you can see 401 and 403, but not why they happened or which user/account was affected.
Fix: emit explicit app-level events for auth, authz, session, and admin actions; keep nginx logs only for edge visibility.
No local queue or disk buffer on the forwarder
Mistake: sending directly from the app to the SIEM synchronously, or forwarding without buffering.
Symptom: login requests slow down during SIEM outages, or events disappear during network blips.
Fix: write locally first and forward asynchronously with rsyslog disk queue (queue.filename, queue.maxdiskspace, action.resumeRetryCount="-1").
Multiline JSON or stack traces in the event stream
Mistake: pretty-printing JSON or mixing stack traces into the same file the forwarder tails.
Symptom: the SIEM ingests broken fragments, parse failures, or one event becomes many.
Fix: write one compact JSON object per line to a dedicated security log file.
TLS name mismatch on syslog forwarding
Mistake: Target is an IP address but StreamDriverPermittedPeers expects a hostname from the certificate.
Symptom: rsyslog connects and immediately drops; logs show peer name validation errors.
Fix: set Target and StreamDriverPermittedPeers to the certificate DNS name, then retest with openssl s_client -servername <name>.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI