MCP Server Security — Audit Log Integrity

MCP server audit log integrity — HMAC-chained entries, append-only storage, tamper-evident log forwarding, and sequence gap detection

An MCP server that logs every tool call provides useful forensics — until someone with database access edits or deletes those logs. Standard audit log tables in SQLite, PostgreSQL, or MySQL have no protection against UPDATE or DELETE by a database user with write access. A compromised server, a malicious insider, or an attacker who pivoted to the database can silently remove evidence of their tool call activity. HMAC-chained log entries (each record includes the HMAC of the previous record's canonical form), append-only table enforcement, forwarding to a tamper-evident sink, and sequence number gap detection make log manipulation detectable after the fact. This page covers each pattern with Node.js and SQL implementations.

Attack 1: No cryptographic chain — silent log editing

Without cryptographic chaining, any audit log can be silently manipulated by anyone with database write access. An attacker who gains database credentials — via SSRF to an internal metadata endpoint, via compromised CI/CD secrets, or via a credential-stealing MCP tool call — can DELETE or UPDATE rows from the audit log to remove evidence of their activity. The log continues to look valid: timestamps are sequential, the entry count is plausible, and no external system knows records were removed.

// DANGEROUS: plain audit log — no chaining, no sequence number
async function logToolCall(db, sessionId, toolName, args, result) {
  await db.run(
    `INSERT INTO audit_log (session_id, tool_name, args_json, result_json, created_at)
     VALUES (?, ?, ?, ?, ?)`,
    [sessionId, toolName, JSON.stringify(args), JSON.stringify(result), new Date().toISOString()]
  );
  // Any db user with DELETE can remove this row — no detection possible
}

// -----------------------------------------------------------------------

// SAFE: HMAC-chained log entries + sequence numbers

import { createHmac } from 'crypto';

const CHAIN_SECRET = process.env.AUDIT_CHAIN_SECRET; // 32+ bytes, stored in secrets manager

function canonicalizeEntry(entry) {
  // Deterministic JSON — sorted keys, no undefined, no Date objects
  return JSON.stringify({
    seq: entry.seq,
    session_id: entry.session_id,
    tool_name: entry.tool_name,
    args_hash: entry.args_hash, // SHA-256 of args JSON, not raw args (size limit)
    result_hash: entry.result_hash,
    created_at: entry.created_at, // ISO 8601 string
    prev_hash: entry.prev_hash,   // HMAC of previous entry's canonical form
  });
}

function computeEntryHmac(entry) {
  return createHmac('sha256', CHAIN_SECRET)
    .update(canonicalizeEntry(entry))
    .digest('hex');
}

async function logToolCallChained(db, sessionId, toolName, args, result) {
  // Fetch last entry to continue the chain
  const last = await db.get(
    `SELECT seq, entry_hmac FROM audit_log ORDER BY seq DESC LIMIT 1`
  );
  const prevSeq = last?.seq ?? 0;
  const prevHash = last?.entry_hmac ?? '0'.repeat(64); // genesis entry: all zeros
  const seq = prevSeq + 1;
  const now = new Date().toISOString();

  const argsHash = createHmac('sha256', CHAIN_SECRET).update(JSON.stringify(args)).digest('hex');
  const resultHash = createHmac('sha256', CHAIN_SECRET).update(JSON.stringify(result)).digest('hex');

  const entry = { seq, session_id: sessionId, tool_name: toolName, args_hash: argsHash,
    result_hash: resultHash, created_at: now, prev_hash: prevHash };
  const entryHmac = computeEntryHmac(entry);

  await db.run(
    `INSERT INTO audit_log (seq, session_id, tool_name, args_hash, result_hash,
      created_at, prev_hash, entry_hmac)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [seq, sessionId, toolName, argsHash, resultHash, now, prevHash, entryHmac]
  );
}

// Chain verification: detects any inserted/deleted/modified entries
async function verifyAuditChain(db) {
  const entries = await db.all(`SELECT * FROM audit_log ORDER BY seq ASC`);
  let expectedPrevHash = '0'.repeat(64);

  for (const entry of entries) {
    // 1. Sequence continuity: gaps indicate deleted records
    if (entry.seq !== (entries.indexOf(entry) + 1)) {
      return { valid: false, reason: `Sequence gap at position ${entries.indexOf(entry) + 1}, found seq=${entry.seq}` };
    }

    // 2. Previous hash matches
    if (entry.prev_hash !== expectedPrevHash) {
      return { valid: false, reason: `Chain break at seq=${entry.seq}: prev_hash mismatch` };
    }

    // 3. Entry HMAC is valid
    const expectedHmac = computeEntryHmac(entry);
    if (entry.entry_hmac !== expectedHmac) {
      return { valid: false, reason: `HMAC mismatch at seq=${entry.seq}: entry was modified` };
    }

    expectedPrevHash = entry.entry_hmac;
  }

  return { valid: true, entries: entries.length };
}

Attack 2: Mutable database table — UPDATE and DELETE possible

Chaining helps detect tampering, but it doesn't prevent it. An attacker with database credentials can recompute the chain after deletion if they also know the CHAIN_SECRET. Defense-in-depth requires making the table itself write-once at the database level: grant the MCP server's database user only INSERT permission on the audit table, never UPDATE or DELETE. In PostgreSQL this is straightforward with GRANT; in SQLite, use a separate read-only connection for reads and a write connection with explicit transaction limits.

-- PostgreSQL: append-only audit log table with row-level security

CREATE TABLE audit_log (
  seq          BIGSERIAL PRIMARY KEY,
  session_id   TEXT NOT NULL,
  tool_name    TEXT NOT NULL,
  args_hash    TEXT NOT NULL,
  result_hash  TEXT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  prev_hash    TEXT NOT NULL,
  entry_hmac   TEXT NOT NULL
);

-- Prevent any modification after insert using a trigger
CREATE OR REPLACE FUNCTION audit_log_immutable()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'audit_log rows are immutable — modification not permitted';
END;
$$;

CREATE TRIGGER trg_audit_log_immutable
  BEFORE UPDATE OR DELETE ON audit_log
  FOR EACH ROW EXECUTE FUNCTION audit_log_immutable();

-- Grant INSERT-only to the MCP server's DB role
REVOKE ALL ON audit_log FROM mcp_server_role;
GRANT INSERT ON audit_log TO mcp_server_role;
GRANT USAGE, SELECT ON SEQUENCE audit_log_seq_seq TO mcp_server_role;

-- Separate read role for forensics (never write access)
GRANT SELECT ON audit_log TO audit_reader_role;

Attack 3: No external sink — log deleted with the server

A local-only audit log is destroyed when the server's disk is wiped or the container is restarted without persistent storage. An attacker who compromises the host with root access can delete the entire log database before forensics begins. Forward every audit log entry to an external tamper-evident sink immediately after local write — before the INSERT transaction commits, if possible, or as a synchronous post-insert step.

// Forward to external sink immediately after local write
import { CloudTrailClient, PutEventsCommand } from '@aws-sdk/client-cloudtrail';

const cloudtrail = new CloudTrailClient({ region: process.env.AWS_REGION });

async function logToolCallWithForwarding(db, sessionId, toolName, args, result) {
  // 1. Write locally with chain (as above)
  const entry = await logToolCallChained(db, sessionId, toolName, args, result);

  // 2. Forward to CloudTrail (immutable, S3-backed, 90-day default retention)
  try {
    await cloudtrail.send(new PutEventsCommand({
      Events: [{
        EventName: 'McpToolCall',
        EventTime: new Date(),
        Resources: [{
          ResourceType: 'McpSession',
          ResourceName: sessionId,
        }],
        AdditionalEventData: JSON.stringify({
          tool: toolName,
          seq: entry.seq,
          entry_hmac: entry.entryHmac, // for cross-referencing with local chain
        }),
        AccessKeyId: process.env.MCP_CLOUDTRAIL_KEY_ID,
      }],
    }));
  } catch (err) {
    // Forwarding failure: log locally but mark entry as not-forwarded
    // Alert security team — entries not forwarded weaken tamper-evidence
    logger.error('CloudTrail forwarding failed', { seq: entry.seq, error: err.message });
    await db.run('UPDATE audit_log SET forwarded = 0 WHERE seq = ?', [entry.seq]);
  }
}

// Alternative: forward to S3 with Object Lock (COMPLIANCE mode)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});
async function forwardToS3Locked(entry) {
  await s3.send(new PutObjectCommand({
    Bucket: process.env.AUDIT_BUCKET,
    Key: `audit/${entry.seq.toString().padStart(12, '0')}.json`,
    Body: JSON.stringify(entry),
    ContentType: 'application/json',
    ObjectLockMode: 'COMPLIANCE',
    ObjectLockRetainUntilDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
  }));
}

Attack 4: No gap detection — deleted rows unnoticed

Sequence number gaps in log records reveal deleted entries even without HMAC chaining. A log with entries at seq 1, 2, 3, 5, 6 tells you seq 4 was deleted — even if the attacker recomputed the HMAC chain to hide the modification. Build gap detection into your log monitoring and run it continuously, not just during manual forensic review.

// Continuous gap detection — run every 5 minutes via setInterval or cron
async function detectLogGaps(db) {
  const gaps = await db.all(`
    SELECT
      a.seq + 1 AS gap_start,
      MIN(b.seq) - 1 AS gap_end,
      MIN(b.seq) - a.seq - 1 AS missing_count
    FROM audit_log a
    LEFT JOIN audit_log b ON b.seq > a.seq
    GROUP BY a.seq
    HAVING MIN(b.seq) > a.seq + 1
    ORDER BY gap_start
  `);

  if (gaps.length > 0) {
    logger.error('AUDIT LOG INTEGRITY ALERT: sequence gaps detected', { gaps });
    // Alert PagerDuty / security SIEM immediately
    await sendSecurityAlert('audit_log_gap', { gaps, detectedAt: new Date().toISOString() });
  }

  return gaps;
}

// Also detect the current highest seq vs expected count
async function detectCountMismatch(db) {
  const { maxSeq } = await db.get('SELECT MAX(seq) AS maxSeq FROM audit_log');
  const { count } = await db.get('SELECT COUNT(*) AS count FROM audit_log');

  if (maxSeq !== count) {
    logger.error('AUDIT LOG INTEGRITY ALERT: count/sequence mismatch', {
      maxSeq,
      count,
      missing: maxSeq - count,
    });
  }
}

SkillAudit findings for audit log integrity

SkillAudit Findings — Audit Log Integrity

CRITICAL−22 pts: Audit log table has no UPDATE/DELETE trigger and database role has write permissions beyond INSERT. Any authenticated database user can silently modify or delete tool call history.
HIGH−18 pts: No HMAC chaining between audit log entries. Deleted or modified records are undetectable without an external reference point.
HIGH−16 pts: Audit log written only to local storage with no external forwarding. Host compromise or container restart without persistent volume destroys forensic record.
MEDIUM−8 pts: No sequence number in log entries and no gap detection monitoring. Deleted records are invisible without a reference sequence.

Run a free SkillAudit scan to check your MCP server's audit logging implementation. The scanner inspects log write paths for HMAC chaining, checks database schema for UPDATE/DELETE triggers and role grants, detects whether logs are forwarded to an external sink, and verifies sequence numbering in existing log records. Related: distributed tracing security for complementary observability patterns and secrets rotation for rotating the HMAC chain secret without breaking chain continuity.