← Blog

Claude skill logging and observability exposure

Logging is how skill authors debug in production — and how attackers exfiltrate data without touching the skill’s primary output. Claude skills that log tool arguments at INFO level send user queries and document content to SIEM indexers and log aggregators accessible to anyone with log-read permissions. Skills that include session tokens in log lines enable log-based session hijacking. Skills that emit nanosecond-precision timing data leak cryptographic side-channels. Skills with no log rotation exhaust disk and crash the host process. These four patterns form the SA-SKL-LOG family.

By SkillAudit  |  2 Nov 2026

Every console.log, console.error, or structured logger call in a Claude skill writes to the MCP host’s stderr stream, which is forwarded to whatever log pipeline the operator has configured — local files, Datadog, Splunk, AWS CloudWatch, or a shared SIEM platform. In hosted, multi-tenant deployments the same log stream carries data from every user’s tool invocations. Unlike tool result content (which stays in the LLM conversation), log entries are often retained for weeks or months for debugging purposes, indexed in full-text search, and readable by any operator or developer with log access. A single console.log('Tool called:', JSON.stringify(args)) line at the top of a tool handler writes every argument the LLM passed — document content, user queries, email drafts, file paths — to that persistent, searchable, widely-accessible store.

SA-SKL-LOG-001: tool arguments logged verbatim expose user content to structured log pipelines

The most common pattern is a debugging line added at the start of a tool handler that logs all incoming arguments. The developer adds it during local development to see what the LLM is passing, deploys with it still enabled, and never revisits it because the skill “works.” In production, this line writes the complete tool arguments to the log stream for every invocation. Arguments carry document content (the LLM passed a 50,000-word document to the summarize tool), user queries (the search term the user typed), email draft content, or structured data including names and account numbers. This data flows into log aggregators where it is indexed, retained, and accessible to anyone with log-read access — a far wider audience than the encrypted conversation the user expected their data to stay in.

// VULNERABLE: verbatim argument logging at INFO level
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new Server({ name: 'doc-skill', version: '1.0.0' });

server.setRequestHandler('tools/call', async (req) => {
  const { name, arguments: args } = req.params;

  // Dumps all tool arguments to stderr on every call
  console.log(`Tool called: ${name}`, JSON.stringify(args));
  // ^ args may contain: document content, user queries, email drafts,
  //   file paths, personal names — all written to log aggregator at INFO

  if (name === 'summarize_document') {
    const { content } = args;  // may be thousands of words of user content
    return { content: [{ type: 'text', text: await summarize(content) }] };
  }
});

// ALSO VULNERABLE: structured logger with full args object
import pino from 'pino';
const log = pino();

server.setRequestHandler('tools/call', async (req) => {
  log.info({ toolName: req.params.name, args: req.params.arguments }, 'tool invoked');
  // ^ pino serializes entire args object; Datadog/Splunk indexes every field
  // ^ full-text search for "john.smith@company.com" returns all tool invocations
  //   where that email appeared in any argument value
});

// ALSO VULNERABLE: debug flag that stays true in npm package
const DEBUG = process.env.DEBUG_SKILL ?? true;  // ← default true — all users get logging

server.setRequestHandler('tools/call', async (req) => {
  if (DEBUG) console.log('Args:', req.params.arguments);  // every user's data logged
});

// SECURE: log only non-sensitive metadata — tool name and argument key names, never values
server.setRequestHandler('tools/call', async (req) => {
  const { name, arguments: args } = req.params;
  const argKeys = args ? Object.keys(args) : [];

  // Log shape (safe: just key names) not content (unsafe: values may contain PII)
  console.log(`[tool] ${name} keys=[${argKeys.join(',')}]`);

  // If content-length is useful for performance debugging, log only the length
  if (args?.content) {
    console.log(`[tool] ${name} content_length=${String(args.content).length}`);
  }
});

// DETECTION (SkillAudit):
// Search for console.log/console.error/logger calls inside tools/call handler
// with arguments object or individual argument values interpolated
const verbatimArgLog = searchAST(
  'CallExpression[callee=/console\\.(log|error|warn|info)/] inside tools/call handler ' +
  'where arguments reference req.params.arguments or destructured arg values'
);
if (verbatimArgLog.length > 0) → HIGH  // SA-SKL-LOG-001

DEBUG defaults are a production vulnerability: process.env.DEBUG_SKILL ?? true means every install logs unless the user explicitly opts out. Skills distributed via npm are installed on machines where operators do not set obscure env vars. Log-level defaults must be false or 'error' only; opt-in verbose logging, not opt-in silence.

HIGH
SA-SKL-LOG-001 — tool arguments logged verbatim at INFO/DEBUG level: user document content, queries, and PII written to log aggregators accessible to all operator staff; full-text search retrieves users’ private data by name, email, or content keywords. Detection: console.log inside tools/call handler referencing argument values → HIGH.

SA-SKL-LOG-002: session tokens or request IDs in log output enable log-based session hijacking

Skills that correlate log events across a session often include the MCP request ID, a session token, or an API key fragment in log lines. The intent is to filter all log events for one session when debugging. The consequence is that any attacker with log read access — a developer, a devops engineer, a compromised SIEM account, or an overpermissioned log aggregator API key — can extract live session tokens from the log stream and replay them against the skill or the upstream API. Unlike a compromised database, a log exfiltration delivers tokens that are still valid at the time of theft. Bearer tokens for APIs like GitHub, Anthropic, or Stripe that appeared in log lines during debugging sessions may remain valid for hours or days after the log entry was written.

// VULNERABLE: request ID and Authorization header fragment logged together
server.setRequestHandler('tools/call', async (req) => {
  const requestId = req.params._meta?.requestId;
  const authHeader = req.params._meta?.authorization;

  console.log(`[${requestId}] Processing tool call, auth=${authHeader?.slice(0, 20)}...`);
  // ^ First 20 chars of "Bearer ghp_ABCDEF..." may be sufficient to identify
  //   and guess the full token (base62 tokens have limited entropy per prefix)
  // ^ requestId in same line creates a correlation key for session reconstruction
});

// ALSO VULNERABLE: session token logged on error paths
async function callUpstreamAPI(token, query) {
  try {
    return await fetch('https://api.example.com/data', {
      headers: { Authorization: `Bearer ${token}` }
    }).then(r => r.json());
  } catch (err) {
    // Logs full token on network error — common in debugging sessions
    console.error(`API call failed for token ${token}:`, err.message);
    // ^ Full live token in log line; log aggregator indexes it permanently
    throw err;
  }
}

// ALSO VULNERABLE: structured log with auth context
import pino from 'pino';
const log = pino();

server.setRequestHandler('tools/call', async (req) => {
  log.info({
    requestId: req.id,
    sessionToken: req.params._meta?.sessionToken,  // ← full token in structured field
    tool: req.params.name
  }, 'tool dispatched');
  // ^ SIEM query: { sessionToken: * } returns all active sessions
});

// SECURE: log correlation IDs derived from tokens, never the tokens themselves
import crypto from 'crypto';

function tokenCorrelationId(token) {
  // One-way hash — sufficient for log correlation, useless for replay
  return crypto.createHash('sha256').update(token).digest('hex').slice(0, 12);
}

server.setRequestHandler('tools/call', async (req) => {
  const token = req.params._meta?.sessionToken;
  const corrId = token ? tokenCorrelationId(token) : 'anon';

  console.log(`[${corrId}] tool=${req.params.name}`);
  // Correlation possible; token not recoverable from log entry
});

// DETECTION (SkillAudit):
// Find log calls where the logged value is derived from _meta.authorization,
// _meta.sessionToken, process.env.*_KEY, or process.env.*_TOKEN
const tokenInLog = searchAST(
  'log calls inside tools/call where arguments include ' +
  'process.env.*KEY or process.env.*TOKEN or _meta.authorization or _meta.sessionToken'
);
if (tokenInLog.length > 0) → CRITICAL  // SA-SKL-LOG-002

Log aggregators are not secrets managers: SIEM platforms like Splunk, Datadog, and CloudWatch index log entries for full-text search and retain them for compliance periods (often 90 days to 7 years). A token written to a log line in November will be searchable in March. Even if the token has expired, the log entry proves the token existed and may reveal the scope or permissions it held — useful for social engineering or CVE cross-referencing.

CRITICAL
SA-SKL-LOG-002 — session tokens, API keys, or Authorization header values in log output: log-read access translates directly to active session hijacking; tokens indexed in SIEM remain searchable after expiry. Detection: log calls referencing process.env.*TOKEN, *KEY, or _meta.authorization → CRITICAL.

SA-SKL-LOG-003: high-precision timing in log output enables side-channel reconstruction

Skills that log timestamps around cryptographic operations, conditional branches on secret comparisons, or database queries with indexed secret columns introduce timing side-channels recoverable from log analysis. Node.js’s Date.now() has millisecond resolution, performance.now() has microsecond resolution, and process.hrtime.bigint() has nanosecond resolution. When a skill logs the start and end time of an HMAC comparison or a database lookup keyed on a secret token, an attacker with log access can compute the execution duration and infer whether a secret matched without making any direct API calls. This attack is relevant when the skill handles multi-user scenarios where one user can submit candidate tokens while observing log timing data that correlates with another user’s session state.

// VULNERABLE: high-resolution timing around secret-key comparison
import crypto from 'crypto';

async function verifyWebhookSignature(payload, signature, secret) {
  const start = process.hrtime.bigint();  // nanosecond precision
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  const valid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  const elapsed = process.hrtime.bigint() - start;

  // Logs nanosecond duration of HMAC computation alongside signature
  console.log(`[webhook] sig_valid=${valid} duration_ns=${elapsed} sig_prefix=${signature.slice(0,8)}`);
  // ^ timingSafeEqual is constant-time, but the surrounding code is not:
  //   HMAC computation duration varies with CPU load and secret key schedule.
  //   Attacker collects 10,000 log entries; shorter durations correlate with
  //   correct secret guesses via cache timing on HMAC key expansion.
  return valid;
}

// ALSO VULNERABLE: timing around DB lookup keyed on token
async function lookupSession(token) {
  const t0 = performance.now();
  const row = await db.get('SELECT * FROM sessions WHERE token = ?', token);
  const t1 = performance.now();

  console.log(`[session] found=${!!row} lookup_ms=${(t1 - t0).toFixed(3)}`);
  // ^ DB indexed on token: hit vs. miss has different timing
  // ^ Attacker observes that valid tokens produce shorter lookups (index hit)
  //   vs. invalid tokens (index miss → full scan)
  return row;
}

// ALSO VULNERABLE: branch timing leak through conditional log path
async function checkPermission(userId, resource, secretPart) {
  const t = Date.now();
  const allowed = secretPart === process.env.ADMIN_KEY;  // ← non-constant-time string compare
  const elapsed = Date.now() - t;

  if (elapsed > 0) {
    // Log entry exists only on slower (matching prefix) comparisons
    // Absence of log entry reveals non-match; presence reveals partial match
    console.log(`[auth] permission check elapsed ${elapsed}ms`);
  }
  return allowed;
}

// SECURE: log aggregate counts, not per-call durations for sensitive operations
let webhookChecks = 0, webhookFails = 0;

async function verifyWebhookSignatureSafe(payload, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  const valid = crypto.timingSafeEqual(
    Buffer.from(signature.padEnd(64, '0')),
    Buffer.from(expected.padEnd(64, '0'))
  );

  // Count only — no per-call timing
  webhookChecks++;
  if (!valid) webhookFails++;

  // Emit aggregate metrics periodically, not per-call
  if (webhookChecks % 1000 === 0) {
    console.log(`[webhook] checks=${webhookChecks} fail_rate=${(webhookFails/webhookChecks).toFixed(4)}`);
  }
  return valid;
}

// DETECTION (SkillAudit):
// Find log calls that include performance.now(), process.hrtime.bigint(), or Date.now() delta
// inside or within 5 lines of crypto operations or DB queries keyed on secret values
const timingInLog = searchAST(
  'log call within 10 lines of crypto.createHmac, crypto.timingSafeEqual, or db.get/db.query ' +
  'where log arguments include hrtime, performance.now(), or elapsed time variable'
);
if (timingInLog.length > 0) → MEDIUM  // SA-SKL-LOG-003

timingSafeEqual is not enough: crypto.timingSafeEqual only eliminates timing variance in the comparison itself. Surrounding code — HMAC key expansion, Base64 decoding, memory allocation — still varies. Logging the total elapsed time of a function that calls timingSafeEqual captures that surrounding variance, defeating the constant-time comparison. Never log durations of authentication or cryptographic operations, even when using constant-time primitives.

MEDIUM
SA-SKL-LOG-003 — high-precision timing in log output near cryptographic operations or secret comparisons: log-read access enables offline timing side-channel analysis; valid token guesses produce measurably different log durations. Detection: process.hrtime or performance.now() delta in log calls adjacent to crypto.* operations → MEDIUM.

SA-SKL-LOG-004: log rotation absent causes disk exhaustion DoS

Skills that write to local log files — via fs.createWriteStream, pino with a file destination, or winston with a file transport — without configuring rotation accumulate unbounded log data. In long-running MCP host processes serving many users, a verbose skill can produce tens of megabytes of logs per hour. Without rotation and maximum-file-size limits, the log file grows until the disk is full. When the disk fills, the Node.js process cannot allocate buffers for network I/O, crashes on the next fs.write call, or hangs on log flush — denying service to all users on the host. An attacker aware of the log verbosity can deliberately trigger high-volume tool calls to accelerate disk exhaustion, turning log misconfiguration into a targeted DoS primitive. Even without attacker involvement, a long-running skill in a Docker container with a limited volume mount will naturally DoS itself within days.

// VULNERABLE: fs.createWriteStream without rotation — unbounded growth
import fs from 'fs';
import path from 'path';

const logStream = fs.createWriteStream(
  path.join(process.cwd(), 'skill.log'),
  { flags: 'a' }   // append — never truncated, never rotated
);

server.setRequestHandler('tools/call', async (req) => {
  logStream.write(`${new Date().toISOString()} tool=${req.params.name} args=${JSON.stringify(req.params.arguments)}\n`);
  // ^ 500 tool calls/hour × 2KB average args = 1MB/hour = 24MB/day = 720MB/month
  // ^ Docker container with 1GB data volume: DoS in ~5 weeks without any attacker
});

// ALSO VULNERABLE: pino file destination without rotation
import pino from 'pino';
const log = pino(pino.destination('./skill.log'));
// pino.destination does not rotate; must be wrapped with pino-roll or similar

// ALSO VULNERABLE: winston with file transport, no maxsize/maxFiles
import winston from 'winston';
const logger = winston.createLogger({
  transports: [
    new winston.transports.File({
      filename: 'skill.log'
      // Missing: maxsize, maxFiles, tailable
    })
  ]
});
// ^ With no maxsize/maxFiles: single file grows without bound

// SECURE: winston file transport with rotation limits
import winston from 'winston';
const logger = winston.createLogger({
  level: 'error',  // only errors — not INFO verbosity
  transports: [
    new winston.transports.File({
      filename: 'skill.log',
      maxsize: 10 * 1024 * 1024,   // 10MB max per file
      maxFiles: 5,                  // keep only 5 rotated files (50MB total cap)
      tailable: true
    })
  ]
});

// ALSO SECURE: pino with pino-roll for time-based rotation
import pino from 'pino';
import { build } from 'pino-roll';

const transport = await build('./skill-log', {
  frequency: 'daily',
  mkdir: true,
  size: '10m',   // rotate at 10MB regardless of time
  limit: { count: 7 }  // keep 7 files
});
const log = pino({ level: 'warn' }, transport);

// DETECTION (SkillAudit):
// Find fs.createWriteStream to a .log file without piping through a rotate stream
const unboundedStream = searchAST(
  'fs.createWriteStream(*.log) not wrapped in rotating-file-stream or pino-roll or similar'
);
// Find winston File transport without maxsize or maxFiles
const winstonUnbound = searchAST(
  'new winston.transports.File({...}) where maxsize is absent or maxFiles is absent'
);
// Find pino.destination without rotation wrapper
const pinoUnbound = searchAST(
  'pino.destination(...) not wrapped in pino-roll build()'
);
if (unboundedStream || winstonUnbound || pinoUnbound) → HIGH  // SA-SKL-LOG-004

stderr is not safe from disk exhaustion: MCP host processes like Claude Code pipe skill stderr to their own log pipeline. If the skill emits at high volume to stderr, the host’s log file (not the skill’s) fills the disk. The host process then crashes, taking every skill it hosts with it — a multi-tenant DoS from a single verbose skill. Even when the skill writes only to stderr (no file), log-level discipline (WARN/ERROR only) and argument redaction are essential.

HIGH
SA-SKL-LOG-004 — log files written without rotation limits: disk exhaustion crashes the MCP host process or all co-hosted skills; attacker-driven tool-call flood accelerates exhaustion to a practical DoS timeline. Detection: fs.createWriteStream, pino.destination, or winston.transports.File without rotation config → HIGH.

SA-SKL-LOG summary

IDPatternSeverityImpact
SA-SKL-LOG-001Tool arguments logged verbatim at INFO levelHIGHUser document content and PII written to SIEM-indexed log pipelines
SA-SKL-LOG-002Session tokens or API keys in log outputCRITICALLog-read access converts directly to active session hijacking
SA-SKL-LOG-003High-precision timing logged near crypto operationsMEDIUMLog-accessible timing side-channel for secret comparison attacks
SA-SKL-LOG-004Log rotation absent on file transportsHIGHDisk exhaustion DoS crashes MCP host and all co-hosted skills

How SkillAudit detects SA-SKL-LOG patterns

SkillAudit’s static analysis phase uses AST traversal to locate console.log, console.error, pino, and winston logger calls inside MCP tools/call request handlers. For each log call, it inspects the logged values: direct references to req.params.arguments, destructured argument variables, or interpolated argument values trigger SA-SKL-LOG-001. Log calls that include process.env.*TOKEN, process.env.*KEY, or _meta.authorization in their arguments trigger SA-SKL-LOG-002. High-resolution timing — process.hrtime.bigint(), performance.now(), or Date.now() delta variables — logged within 10 lines of crypto.* calls or database queries trigger SA-SKL-LOG-003. File transport configuration (Winston, pino-destination, fs.createWriteStream) missing rotation parameters triggers SA-SKL-LOG-004.

Run a free SkillAudit scan on your Claude skill or MCP server to check all four SA-SKL-LOG patterns alongside SA-SKL-ENV credential exposure, SA-SKL-TLS certificate validation bypass, SA-SKL-SRL unsafe deserialization, and SA-SKL-RCL resource lifecycle leakage in a single 60-second report.