Back to API Design Best Practices
Q17Question 17 of 22

How do you make an API audit-proof so every mutation including failed attempts is traceable

Key Concepts
  • Every audit record captures six fields: actor (from security context), action, target, server-side UTC timestamp, correlation ID, and outcome.
  • Capture audit centrally with an interceptor or aspect so no endpoint can forget to log.
  • Log before the action (attempt) and after (outcome) so even a mid-operation crash leaves a trace.
  • The log store must be immutable and append-only; the application user has INSERT permission only.
  • Denied attempts must be logged because they are the signal of an attack in progress.
  • Never log passwords, full card numbers, or tokens; the audit log itself is a high-value target.

Definition

Log who did what to which target, when, under which correlation ID, and with what outcome, for every mutation including the ones you denied.
  • 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 INSERT permission.
  • 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.

JAVA
package com.bank.audit;

import java.time.Instant;

public record AuditRecord(
        String actor,
        String action,
        String target,
        Instant timestamp,
        String correlationId,
        String outcome
) {}
JAVA
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.

JAVA
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.

TEXT
// 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.

SQL
-- 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.

JAVA
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."

JAVA
// 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;
}
DIAGRAM
// 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.

JAVA
// 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.

JAVA
// 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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate