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