Definition
- Audit record – captures the actor (who), action (what), target (on what), timestamp (when), correlation ID (which request chain), and outcome (success/failure/denied).
- Centralized capture – a Spring interceptor, servlet filter, or AOP aspect so every mutating request flows through the same recording logic automatically.
- Immutable log – the store does not allow updates or deletes; the application user has only
INSERTpermission. - Before-and-after logging – the attempt is recorded on arrival, the outcome is recorded after execution, so a crash leaves a trace.
- Denied attempts – must be logged because repeated denials are the strongest signal of an attack in progress.
At National Bank of India, the vault has a security register. Every person who approaches is recorded: not only those allowed in. When Priya tries to open a locker that is not hers and the guard turns her away, that rejected attempt is still written with her name, the time, which locker, and the word "DENIED." An audit-proof API is that register that can never be erased.
Understanding the Concepts
What are the six fields of an audit record?
Every audit record captures six things. The actor comes from the server-side security context, never from a client-supplied field. The action names the operation. The target identifies the resource. The timestamp is server-side UTC. The correlation ID ties the record to the whole request chain. The outcome is SUCCESS, FAILURE, or DENIED.
package com.bank.audit;
import java.time.Instant;
public record AuditRecord(
String actor,
String action,
String target,
Instant timestamp,
String correlationId,
String outcome
) {}
import org.slf4j.MDC;
import org.springframework.security.core.context.SecurityContextHolder;
AuditRecord record = new AuditRecord(
SecurityContextHolder.getContext().getAuthentication().getName(),
"TRANSFER",
"SAV2001",
Instant.now(),
MDC.get("correlationId"),
"DENIED"
);
Why must audit be centralized?
If each controller method writes its own audit log, some will forget, some will log inconsistently, and a developer adding a new endpoint next year will miss it. A centralized interceptor or aspect ensures every mutating request flows through the same recording logic.
package com.bank.audit;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.slf4j.MDC;
import org.springframework.security.core.context.SecurityContextHolder;
import java.io.IOException;
import java.time.Instant;
public class AuditInterceptor implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!request.getMethod().equals("GET")) {
String actor = SecurityContextHolder.getContext()
.getAuthentication().getName();
String correlationId = MDC.get("correlationId");
// Log BEFORE – the attempt:
auditStore.insert(new AuditRecord(
actor, request.getMethod() + " " + request.getRequestURI(),
request.getRequestURI(), Instant.now(), correlationId, "ATTEMPT"));
chain.doFilter(req, res);
// Log AFTER – the outcome:
String outcome = response.getStatus() < 400 ? "SUCCESS" : "FAILURE";
auditStore.insert(new AuditRecord(
actor, request.getMethod() + " " + request.getRequestURI(),
request.getRequestURI(), Instant.now(), correlationId, outcome));
} else {
chain.doFilter(req, res);
}
}
}
Why log before AND after the action?
Logging before means you record the attempt the moment it arrives, so even if the process crashes mid-operation you still know it was attempted. Logging after records the outcome.
// Before: ATTEMPT recorded at 10:00:01
// Action executes... (process crashes at 10:00:02)
// After: never recorded
// But the ATTEMPT record proves the operation was started
Why must the log be immutable?
The log must be append-only so no one can delete or alter records after the fact. In practice this means the application database user has only INSERT permission on the audit table.
-- Audit table:
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
actor VARCHAR(100) NOT NULL,
action VARCHAR(200) NOT NULL,
target VARCHAR(200) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
correlation VARCHAR(50) NOT NULL,
outcome VARCHAR(20) NOT NULL
);
-- Application user has INSERT only:
GRANT INSERT ON audit_log TO app_user;
-- No UPDATE, no DELETE
For tamper evidence, hash each record together with the previous record's hash, forming a chain. Any later edit breaks the chain.
public String computeHash(AuditRecord record, String previousHash) {
String data = previousHash + record.actor() + record.action() +
record.target() + record.timestamp() + record.outcome();
return DigestUtils.sha256Hex(data);
}
Why must you log denied attempts?
If you only log successes, an attacker probing the API leaves no trace. But denied attempts are exactly the signal of an attack. Logging denials lets the fraud team see patterns like "this identity tried to touch forty accounts in a minute and was denied each time."
// The authorization filter logs DENIED before returning 403:
if (!authorized) {
auditStore.insert(new AuditRecord(
actor, "TRANSFER", "SAV2001", Instant.now(), correlationId, "DENIED"));
response.setStatus(403);
return;
}
// Fraud dashboard query: SELECT actor, COUNT(*) FROM audit_log WHERE outcome = 'DENIED' AND timestamp > NOW() - INTERVAL '1 hour' GROUP BY actor HAVING COUNT(*) > 10; // → "rajesh_attacker" with 47 denials in the last hour
What must never appear in audit logs?
The audit record names the account and the action but masks or omits passwords, full card numbers, and tokens. The audit log is itself a high-value target.
// Wrong – logging the password:
log.info("Login attempt: user={} password={}", username, password); // NEVER
// Correct – logging the attempt without the secret:
log.info("AUDIT action=LOGIN actor={} outcome={}", username, outcome);
How does the correlation ID work across microservices?
A correlation ID is generated at the API gateway and propagated through headers to every downstream service. Each audit record includes it, so you can trace one logical operation across the entire chain.
// Gateway generates and sets the correlation ID:
String correlationId = UUID.randomUUID().toString();
MDC.put("correlationId", correlationId);
response.setHeader("X-Correlation-Id", correlationId);
// Every downstream service reads and propagates it:
String correlationId = request.getHeader("X-Correlation-Id");
MDC.put("correlationId", correlationId);
What should a senior engineer know?
In banking and under regulations like SOX and PCI-DSS, failed and denied access attempts are mandatory to record. The audit log is a legal and investigative instrument, not a debug convenience. Access to the audit log is itself restricted and encrypted. The immutable chain hash makes any tampering detectable. A senior engineer also knows that high-volume audit logging can be offloaded to Kafka as an append-only stream, processed asynchronously, and stored in a separate data store from the main application database.
Visual Overview
Audit Flow:
Client request
│
▼
┌────────────────────────┐
│ Audit Interceptor │
│ 1. Record ATTEMPT │──→ Append-only audit store
│ (actor, action, │
│ target, timestamp,│
│ correlationId) │
└──────────┬─────────────┘
│
▼
┌────────────────────────┐
│ Authorization Check │
│ If denied: │
│ Record DENIED ────────│──→ Append-only audit store
│ Return 403 │
└──────────┬─────────────┘
│
▼
┌────────────────────────┐
│ Service Logic │
└──────────┬─────────────┘
│
▼
┌────────────────────────┐
│ Record OUTCOME │──→ Append-only audit store
│ (SUCCESS or FAILURE) │
└────────────────────────┘
Key Takeaway
An audit-proof API records every state-changing operation and every attempt at one, including denied and failed attempts, in an immutable append-only log. Each record captures six things: the actor from the server-side security context, the action, the target resource, a server-side UTC timestamp, a correlation ID for cross-service tracing, and the outcome. This is implemented centrally with an interceptor so no endpoint can forget to log.
The log is written before the action (the attempt) and after the action (the outcome), so even a mid-operation crash leaves evidence. The store is append-only, meaning the application user has INSERT permission only, and tamper evidence is added by hashing each record with the previous hash, forming a chain.
The reason denied attempts must be logged is that they are the evidence of an attack. At National Bank of India, the fraud team once found nothing during an investigation because the old system logged only successful transfers. An attacker had tried fifty unauthorized transfers, all blocked by authorization, and left zero trace. After the centralized audit interceptor was added, a burst of denials from one identity became a clear alarm.
An audit log is a legal and investigative instrument, not a debug convenience. It never contains passwords, full card numbers, or tokens, because the log itself is a high-value target that must be access-controlled and encrypted.
Remember one sentence: log the attempt even when you deny it, because the attempts you blocked are often more important to record than the ones you allowed.
---