Centralized tamper-evident logging for ISO 27001, SOC 2 and NIS2
For developers and platform engineers who need one logging design that stands up to ISO 27001, SOC 2, and NIS2 reviews without building three separate systems. This guide shows a practical architecture, the concrete mechanics, and the trade-offs so you can decide whether to implement append-only centralized logs with integrity checks and retention controls.
TL;DR — The simplest logging design that satisfies ISO 27001, SOC 2, and NIS2 at the same time is: send security-relevant events from every system to a central log pipeline, store them in append-only storage with tight access control, add integrity protection (hash chaining or signed batches), sync time with NTP, and define retention plus alerting for key events. If you only change one thing, stop treating application logs as the source of truth and move to centralized, immutable, access-audited event collection. Reading time: ~7 min
What it is and where it sits
This is not “buy a SIEM.” It is a logging design pattern:
- Every component emits structured events.
- Events go to a central collector over TLS.
- The collector normalizes, timestamps, and enriches them.
- Events are written to two places:
- hot searchable storage for operations and investigations
- immutable archive for retention and evidence
- Access to read, export, and delete is itself logged.
- Integrity checks prove the archive was not silently altered.
That pattern maps cleanly to all three frameworks because they all care about the same things in practice: traceability, incident detection, retention, integrity, and controlled access. The wording differs; the engineering requirement is basically the same.
What talks to it:
- application services
- API gateways / reverse proxies
- operating system audit logs
- cloud control plane audit trails
- identity provider sign-in events
- database audit logs if you have them
- CI/CD and admin tooling for privileged actions
What it replaces:
- SSHing into boxes and grepping
/var/log - app-only logs with no auth/admin trail
- mutable log files on the same VM that generated them
- separate “compliance logs” and “engineering logs” that drift apart
Where it lives in the flow: beside your production path, not inline with business logic. Apps should be able to continue serving traffic if the log backend is temporarily degraded, usually via local buffering.
View as text
[User] ---- 1. HTTPS request ----> [API/App]
|
| 2. structured event
v
[Local agent]
|
| 3. TLS ship
v
[Collector]
| |
4. alertable --->| |----> 5. immutable archive
hot index v
[Search store]
[IdP] -------- sign-in events --------> [Collector]
[Cloud audit] -- admin/API events ----> [Collector]
[DB audit] ---- query/admin events ---> [Collector]The key architecture decision: the collector and archive are separate from the systems being observed. If an attacker gets root on one app node, they should not be able to rewrite yesterday’s logs for the whole estate.
How it actually works
Walk one realistic example: a production admin changes a customer’s billing plan through an internal admin UI, and later the customer reports unauthorized changes.
Step 1: the app emits one structured security event
When the admin action happens, the app writes JSON, not prose. Include actor, action, target, result, request metadata, and a correlation ID.
Example event shape:
{"ts":"2026-10-30T14:22:11.482Z","service":"billing-api","env":"prod","event_type":"admin.plan_change","severity":"info","actor":{"type":"user","id":"u_12345","email":"admin@example.com","ip":"203.0.113.10"},"target":{"type":"account","id":"acct_987"},"request":{"method":"POST","path":"/admin/accounts/acct_987/plan","request_id":"req_4f0c2a","trace_id":"8b5d7d4d5c1b4d2b"},"change":{"from":"starter","to":"enterprise"},"result":"success"}
If the action fails authorization, log that too. Failed privileged actions matter as much as successful ones.
Step 2: a local agent ships logs off-host
A node-level agent tails stdout, journald, or files and forwards over TLS. This gives you buffering when the network or collector is down.
Typical failure mode if the collector certificate is wrong:
$ openssl s_client -connect logs.internal.example:6514 -servername logs.internal.example </dev/null
CONNECTED(00000003)
depth=0 CN = logs.internal.example
verify error:num=62:hostname mismatch
verify return:1
---
SSL handshake has read 1421 bytes and written 403 bytes
Verification error: hostname mismatch
---
That is the sort of boring operational issue that causes silent log gaps if you do not monitor shipper queues.
Step 3: the collector normalizes and stamps receipt time
The collector should add at least:
ingest_ts: when it received the eventsource: host/container identityschema_version- optional geo/IP enrichment if useful
Keep the original event fields intact. Do not overwrite app timestamps; store both event time and ingest time. During incident review, clock skew shows up immediately.
Step 4: write to hot search and immutable archive
Hot search is for “show me all failed admin logins from this IP in the last 2 hours.” Immutable archive is for “prove this event existed on this date and was not edited later.”
A practical design is signed batches every 1-5 minutes:
- collector groups events into a batch file
- computes SHA-256 of the batch
- writes the batch to object storage with object lock / WORM retention if your platform supports it
- writes the hash and previous batch hash to a ledger record
That creates a chain:
{"batch":"2026-10-30T14:20Z-001.ndjson.gz","sha256":"4f1d...","prev_sha256":"a91c...","count":18422,"signed_at":"2026-10-30T14:25:00Z"}
Now if someone swaps one archived file, the chain breaks. You do not need blockchain nonsense; a simple signed hash chain is enough.
Step 5: detect and alert
Create rules for the events auditors and responders always ask about:
- privileged role assignment
- admin login failures and successes
- disabled logging agent / collector unreachable
- changes to retention policy
- direct production database access
- break-glass account use
Example query logic: alert if event_type=auth.login and result=failure and same actor.ip has count >= 10 in 5 minutes, or if event_type=iam.role_grant in production at all.
Step 6: investigate with one timeline
For the billing-plan incident, you correlate:
- IdP login event for
admin@example.com - app admin action event
admin.plan_change - reverse proxy access log for the same
request_id - cloud audit event if someone changed IAM around the same time
If your logging design is working, you can answer:
- who did it
- from where
- using which account/session
- whether it succeeded
- whether the logs themselves are trustworthy
That is the real compliance outcome. Not “we have logs,” but “we can reconstruct events and defend the evidence.”
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| You handle customer data, have production admins, or need formal audits | Use this design. It is the default. |
| You run multiple services and today logs live on each host | Use this design before your next incident. |
| You need to satisfy ISO 27001, SOC 2, and NIS2 with one control set | Use this design; map one centralized logging control to all three. |
| You are a single internal tool on one VM with no sensitive data | You probably do not need immutable archive yet; centralize logs first. |
| Your app is latency-critical and currently logs synchronously on request path | Do not block requests on remote logging; use async local buffering. |
| You cannot staff on-call for a collector/search cluster | Prefer managed log transport/storage, but keep the same architecture principles. |
You probably do not need the full design if all of these are true: one environment, no customer data, no privileged admin actions, no external compliance requirement, and you can tolerate losing some logs. Most teams reading this are not in that bucket.
Trade-offs
Benefit and cost come together here.
- Centralized investigation → costs pipeline complexity. You now operate agents, collectors, schemas, and retention jobs.
- Tamper evidence / immutability → costs storage money and process discipline. WORM/object-lock retention means deletions are hard by design.
- One design for three frameworks → costs upfront field design. You need a stable event schema and naming conventions.
- Fast incident response → costs indexing spend. Searchable storage is much more expensive than archive storage.
- Reduced attacker ability to cover tracks → costs separation of duties. Developers should not have unrestricted delete access to the log archive.
- Better detection → costs alert tuning. Bad rules create alert fatigue; weak rules create false confidence.
- Evidence quality → costs time sync and integrity verification work. NTP drift, dropped batches, and failed signatures become operational concerns.
The biggest trade-off experienced teams ask about is privacy versus retention. Security logs often contain IPs, emails, object IDs, and sometimes payload fragments. Do not dump full request bodies into your central archive unless you are ready to justify and protect that data. Log decisions and identifiers, not secrets.
In practice
Example 1: nginx emits structured access logs with request IDs
log_format json_combined escape=json
'{"ts":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for",'
'"request_id":"$request_id",'
'"remote_user":"$remote_user",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes_sent":$bytes_sent,'
'"referer":"$http_referer",'
'"user_agent":"$http_user_agent",'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"request_time":$request_time}';
access_log /var/log/nginx/access-json.log json_combined;
This writes machine-parseable access logs you can correlate with app events via request_id. Gotcha: escape=json matters; without it, quotes in user agents or referers will break parsing.
Test and reload:
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
Example 2: rsyslog forwards logs over TLS to a central collector
module(load="imfile")
module(load="omfwd")
module(load="gtls")
global(
DefaultNetstreamDriver="gtls"
DefaultNetstreamDriverCAFile="/etc/ssl/certs/log-ca.pem"
)
input(type="imfile"
File="/var/log/nginx/access-json.log"
Tag="nginx"
Facility="local6"
PersistStateInterval="100")
action(
type="omfwd"
Target="logs.internal.example"
Port="6514"
Protocol="tcp"
StreamDriver="gtls"
StreamDriverMode="1"
StreamDriverAuthMode="x509/name"
StreamDriverPermittedPeers="logs.internal.example"
Template="RSYSLOG_SyslogProtocol23Format"
action.resumeRetryCount="-1"
queue.type="linkedList"
queue.filename="fwdRule1"
queue.maxdiskspace="5g"
queue.saveonshutdown="on")
This tails the nginx JSON log and forwards it with TLS and disk-backed buffering. Gotcha: action.resumeRetryCount="-1" means retry forever; if the collector is down for days and queue.maxdiskspace is too small, old messages will eventually be dropped.
Check status and queue pressure:
$ sudo systemctl restart rsyslog && sudo systemctl status rsyslog --no-pager
● rsyslog.service - System Logging Service
Loaded: loaded (/lib/systemd/system/rsyslog.service; enabled)
Active: active (running) since Fri 2026-10-30 14:31:02 UTC; 2s ago
$ logger -p local6.info 'test message from app01'
If TLS peer validation fails, rsyslog typically logs errors like this to journald or /var/log/syslog:
$ journalctl -u rsyslog -n 20 --no-pager
rsyslogd: omfwd: remote server at logs.internal.example:6514 rejected our connection with error: certificate validation failed [v8.2312.0 try https://www.rsyslog.com/e/2088 ]
Example 3: verify archived batch integrity with sha256sum
⚠️ If you are testing retention or object-lock settings, do it in a non-production bucket/container first. Some immutable-retention settings cannot be shortened after they are applied, which can create real storage cost and operational pain.
$ sha256sum 2026-10-30T14:20Z-001.ndjson.gz
4f1d6b0a7b2d2f0f5e0d6f8d0f2e1c4a1d9b7e3a0d4c2b1e8f9a6d5c3b2a190 2026-10-30T14:20Z-001.ndjson.gz
$ jq . 2026-10-30T14:20Z-001.ledger.json
{
"batch": "2026-10-30T14:20Z-001.ndjson.gz",
"sha256": "4f1d6b0a7b2d2f0f5e0d6f8d0f2e1c4a1d9b7e3a0d4c2b1e8f9a6d5c3b2a190",
"prev_sha256": "a91c2e7d4f...",
"count": 18422,
"signed_at": "2026-10-30T14:25:00Z"
}
This is the minimum viable integrity story: the file hash matches the ledger, and the ledger links to the previous batch. Gotcha: if you regenerate compressed files, the bytes can change even when the event content is the same; hash the exact archived artifact, not a reconstructed export.
Further reading
- NIST SP 800-92: Guide to Computer Security Log Management
- RFC 5424: The Syslog Protocol
- OpenTelemetry Logs Data Model
- OWASP Logging Cheat Sheet
- ISO/IEC 27001 Annex A guidance and the SOC 2 Common Criteria mapping notes from your auditor or advisory firm
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