Security Logs That Help Investigate an Incident

Reconstruct a document publication incident with Spring Boot security logs. Keep every operation aware of the user context, correlate requests, record committed outcomes, and preserve useful evidence without copying secrets.

Alexandru DancauOctober 26, 2026 · 6 min read
Two structured log records for Alice, denied request A and committed request B, are linked along a timeline beneath a magnifying glass.

Alice finds that her private draft is now public, but says she did not publish it. The application recorded:

Document published successfully

We need more: the authenticated account, document, request, and whether the change committed.

This simulated document application builds on Authorization Beyond Roles in Spring Boot, using Java 21, Spring Boot 3.5, Spring Security, and Spring Data JPA.

Record the actor and the outcome

For publication events, use a consistent set of fields:

Field Purpose
@timestamp Record emission time, including the zone offset
service_name Application that emitted the record
event_id Unique event identifier
request_id Correlate events within one HTTP request
actor_id Authenticated account
document_id Requested resource
document_version Row version involved in publication
event and outcome Operation and observed result
reason Stable application value explaining a refusal

Keep these meanings consistent across services. Authentication, authorization, and publication are distinct events. A request ID connects records; it does not establish identity. OWASP logging vocabulary

Keep every operation aware of the user context

Every operation needs an explicit identity and access policy, including reads, lists, searches, exports, updates, deletes, publication, and background work.

Obtain the caller from Spring Security, constrain the operation by their permissions, and carry that identity into its audit event. An owner or actor_id supplied in JSON cannot identify the caller.

Enforce this in application services. Scope list and export queries before pagination, and authorize writes before changing data. In a system with multiple organizations, include the verified organization scope. Adding a username to a log does not secure an unrestricted query. OWASP authorization guidance

For queued work, retain the initiating account and resource scope in server-controlled metadata, then recheck permissions at execution. Distinguish that user from the service executing the job. System maintenance needs its own limited service identity; public operations need an explicit anonymous policy.

Write structured events

Enable Spring Boot's Logstash JSON output:

spring.application.name=security-logs-demo
logging.structured.format.console=logstash
logging.structured.json.add.service_name=security-logs-demo

The formatter includes timestamps, levels, MDC values, and SLF4J key-value fields. MDC is the mapped diagnostic context used to attach metadata to log records. Spring Boot structured logging

A SecurityAudit component records ownership refusals through its SLF4J logger:

public void denied(String actorId, UUID documentId) {
    log.atInfo()
        .addKeyValue("event_id", UUID.randomUUID().toString())
        .addKeyValue("event", "document.publish")
        .addKeyValue("outcome", "denied")
        .addKeyValue("reason", "not_accessible")
        .addKeyValue("actor_id", actorId)
        .addKeyValue("document_id", documentId.toString())
        .log("Document publication");
}

The fluent API keeps fields separate from the constant message. denied describes an observation: a refusal alone does not prove an attack. SLF4J fluent logging

Correlate requests

Generate a request ID on the server, add it to MDC, and return it in the response:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, IOException {
        String previous = MDC.get("request_id");
        String requestId = UUID.randomUUID().toString();

        MDC.put("request_id", requestId);
        response.setHeader("X-Request-ID", requestId);

        try {
            chain.doFilter(request, response);
        } finally {
            if (previous == null) {
                MDC.remove("request_id");
            } else {
                MDC.put("request_id", previous);
            }
        }
    }
}

The filter ignores incoming X-Request-ID values and restores the previous MDC value because servlet threads are reused. Users can quote the response ID when reporting a problem.

This handles synchronous requests. Background work needs explicit propagation of correlation metadata and the correct authorization context; copying a request ID does not establish permissions.

Record publication after commit

Logging success immediately after document.publish() is premature: the transaction can still roll back. Instead, publish an application event inside the transaction:

@Transactional
@PreAuthorize("hasRole('EDITOR')")
public void publish(UUID id) {
    String actorId = currentAccount.username();

    Document document = documents
        .findByIdAndOwnerUsername(id, actorId)
        .orElseThrow(() -> {
            audit.denied(actorId, id);

            return new ResponseStatusException(HttpStatus.NOT_FOUND);
        });

    if (document.isPublished()) {
        return;
    }

    document.publish();
    documents.flush();

    events.publishEvent(new DocumentPublication(
        UUID.randomUUID(),
        actorId,
        id,
        document.getVersion(),
        MDC.get("request_id")
    ));
}

currentAccount.username() reads the authenticated name. Usernames are fixed account keys in this example; use an immutable account ID where usernames can change. @PreAuthorize requires @EnableMethodSecurity and invocation through the Spring proxy. Spring Security method security

Document has a JPA @Version field. Flushing exposes the updated row version, but flush is not commit. Capture the operation's context in the event:

public record DocumentPublication(
    UUID eventId,
    String actorId,
    UUID documentId,
    long documentVersion,
    String requestId
) {
}

The listener records success only after commit:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void committed(DocumentPublication event) {
    String previous = MDC.get("request_id");

    if (event.requestId() == null) {
        MDC.remove("request_id");
    } else {
        MDC.put("request_id", event.requestId());
    }

    try {
        log.atInfo()
            .addKeyValue("event_id", event.eventId().toString())
            .addKeyValue("event", "document.publish")
            .addKeyValue("outcome", "committed")
            .addKeyValue("actor_id", event.actorId())
            .addKeyValue("document_id", event.documentId().toString())
            .addKeyValue("document_version", event.documentVersion())
            .log("Document publication");
    } finally {
        if (previous == null) {
            MDC.remove("request_id");
        } else {
            MDC.put("request_id", previous);
        }
    }
}

A rollback produces no committed event. Without an active transaction, this listener does not run by default. The direct denial log remains valid even when its surrounding transaction rolls back. Spring transaction events

An already published document creates no second transition event. Authentication, role, and CSRF refusals happen before this service body and need separate security-handler or identity-provider events.

Reconstruct the incident

A simplified publication record looks like this:

{
    "@timestamp": "2026-10-25T08:14:07Z",
    "service_name": "security-logs-demo",
    "event_id": "5f468d12-97c0-4ec8-a2a0-dc0488d58cc3",
    "request_id": "9e0b4781-c1f4-45d9-967e-59aa00d24118",
    "actor_id": "alice",
    "document_id": "5f55b830-a150-4edf-88ab-c16ab6ddcc54",
    "document_version": 4,
    "event": "document.publish",
    "outcome": "committed"
}

Our simulated timeline, with shortened request labels, is:

Time in UTC Account Resource Outcome Request
08:14:02 Alice Bob's document Denied A
08:14:07 Alice Alice's document, version 4 Committed B

These records associate two requests with Alice's authenticated account. They do not prove Alice personally made them or that one caused the other. Correlate them with authentication, session lifecycle, and deployment records.

Synchronize clocks and normalize timestamps. Recovering the document's exact text requires revision history; the row version alone does not preserve it.

Protect the evidence

Select fields explicitly. Exclude document content, passwords, authorization headers, and session cookies. Restrict access to account identifiers and define retention. Forward records to protected storage, monitor delivery failures and capacity, and limit deletion permissions. OWASP logging guidance

Keep event names and reasons under application control. Use an encoder that escapes quotes and line breaks, bound input-derived values, and preserve JSON framing throughout collection. Parameter placeholders alone do not prevent plain-text log injection. CWE-117

AFTER_COMMIT does not guarantee durable delivery: the process can stop before the log reaches storage. Where reliable recording is required, write an outbox event in the same database transaction, deliver it with retries, and handle duplicates using its stable event ID. Transactional outbox pattern