Claude skill logging security
Logging is essential for debugging MCP skills in production, but each log statement that includes unfiltered data creates a persistent record of sensitive information in a location that is often less secure than the skill itself. Cloud log aggregators, container log drivers, and log shipping pipelines are shared infrastructure with broader access than the production database — a developer, a contractor, or a compromised CI pipeline runner can read them. SA-SKL-LOG covers four patterns where logging decisions in MCP skills create exploitable exposures: logging the entire tool argument object, logging full HTTP request and response bodies, sending error stack traces to external aggregators, and embedding user-controlled input in log messages without sanitizing CRLF sequences.
Logging vulnerabilities are particularly insidious in MCP skills because the data flow is unusual: the skill receives arguments from Claude, which are ultimately derived from user prompts. This means that anything a user types — passwords entered in a prompt, API keys pasted as context, credit card numbers, PII — can flow into tool arguments and from there into log files if the skill logs its arguments. Unlike a web application where the developer controls the form fields, a Claude skill’s input surface is the full expressive range of natural language, and the user will pass sensitive data in ways the skill author did not anticipate.
SA-SKL-LOG-001: Logging tool argument objects directly — passwords and API keys in cloud logs
The most common logging pattern for debugging a new MCP skill is to log the incoming tool arguments to verify what Claude is passing. console.log('Tool call:', JSON.stringify(args)) or its equivalent is added during development and left in production, or is added to a middleware layer that logs all tool invocations for observability. When users interact with the skill through Claude, they provide context in their prompts that frequently includes sensitive data — passwords for accounts the skill should access, API keys for services, authentication codes, credit card numbers for billing skills, medical information for health skills. All of this flows into args and from there into the log stream:
// VULNERABLE: logging the full args object from Claude tool calls
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 'payment-skill', version: '1.0.0' });
server.tool('process_payment', { amount: z.number(), cardNumber: z.string(), cvv: z.string() },
async (args) => {
// VULNERABLE: logs cardNumber, cvv, any PII in args to stdout → cloud log
console.log('process_payment called:', JSON.stringify(args));
// Also common: a debug middleware that logs all tool calls
logger.debug('Tool invocation', { tool: 'process_payment', args });
return await chargeCard(args.cardNumber, args.cvv, args.amount);
}
);
// Also vulnerable: request-level middleware logging body
app.use((req, res, next) => {
logger.info({ method: req.method, url: req.url, body: req.body }); // body has args
next();
});
// WHAT GETS LOGGED: User prompt said "pay $50 with card 4111111111111111 CVV 123"
// Claude extracted: { amount: 50, cardNumber: '4111111111111111', cvv: '123' }
// Log line: Tool invocation { tool: 'process_payment', args: { amount: 50, cardNumber: '4111111111111111', cvv: '123' } }
// This line is now in: stdout → Docker log driver → CloudWatch / Datadog / Loki
// where: DevOps team, contractors, log retention for 90 days, potential export to SIEM
// SECURE: allowlist-log only fields that are safe to persist
function safeLog(tool: string, args: Record<string, unknown>): void {
// Define safe fields per tool — anything not in this set is omitted
const safeFields: Record<string, Set<string>> = {
process_payment: new Set(['amount']), // log amount, never card/cvv
login: new Set(['username']), // log username, never password
send_message: new Set(['recipient']), // log recipient, never message body
};
const allowed = safeFields[tool] ?? new Set();
const sanitized = Object.fromEntries(
Object.entries(args).filter(([k]) => allowed.has(k))
);
console.log('Tool call:', tool, JSON.stringify(sanitized));
}
// ALTERNATIVE: schema-driven log sanitization
// Mark fields in your Zod schema with a custom .meta({ sensitive: true })
// and strip all sensitive:true fields before logging
Cloud log services are not secret stores: Log streams in AWS CloudWatch, GCP Cloud Logging, Datadog, and Splunk are typically accessible to everyone with developer or DevOps permissions, retained for 30–90 days, potentially exported to SIEM systems, and included in compliance audit trails. A PCI DSS audit will flag plaintext card data in logs as a critical finding. HIPAA auditors treat PHI in logs as equivalent to PHI in the database. Design your logging as if the log stream is public read-access, because for many teams it effectively is.
JSON.stringify(args) in console.log or logger; passwords, API keys, card numbers, PII from user prompts persisted to cloud log stream. Fix: allowlist-log only safe fields per tool; define sensitive fields in Zod schema metadata; never log full args object. Detection: JSON.stringify(args) or args directly in log call → HIGH.SA-SKL-LOG-002: Logging full HTTP request and response bodies — authentication tokens and user data
MCP skills that call external APIs for data retrieval, action execution, or integration commonly add HTTP request/response logging to debug API integration issues. Logging the full request and response bodies captures authentication tokens in Authorization headers, API keys in query parameters or custom headers, OAuth access tokens, and the contents of API responses which may include personal data about users. Response body logging is especially problematic because response data from external APIs often contains fields that were not anticipated during development:
// VULNERABLE: logging full HTTP request and response bodies
// Pattern 1: axios interceptors (very common in MCP skill boilerplate)
import axios from 'axios';
const apiClient = axios.create({ baseURL: 'https://api.example.com' });
apiClient.interceptors.request.use((config) => {
// VULNERABLE: logs full headers including Authorization: Bearer <token>
logger.debug('API request', {
method: config.method,
url: config.url,
headers: config.headers, // includes Authorization header with token
data: config.data, // request body — may contain credentials
});
return config;
});
apiClient.interceptors.response.use((response) => {
// VULNERABLE: logs full response body — may contain PII, tokens, sensitive data
logger.debug('API response', {
status: response.status,
data: response.data, // full response body
});
return response;
});
// Pattern 2: fetch wrapper
async function fetchWithLogging(url: string, options: RequestInit) {
const res = await fetch(url, options);
const body = await res.json();
logger.info({ url, options, body }); // options.headers has auth token; body has user data
return body;
}
// WHAT GETS LOGGED:
// Request: { headers: { Authorization: 'Bearer eyJhbGc...' }, data: { userId: '123' } }
// Response: { data: { email: 'user@example.com', phone: '+1-555-...', ssn: '...' } }
// SECURE: log only method, URL sanitized of query params with secrets, and status code
function safeLogRequest(method: string, url: string, status?: number): void {
// Strip known sensitive query parameters before logging URL
const safeUrl = new URL(url);
const sensitiveParams = new Set(['api_key', 'token', 'access_token', 'secret', 'key']);
for (const param of sensitiveParams) {
if (safeUrl.searchParams.has(param)) {
safeUrl.searchParams.set(param, '[REDACTED]');
}
}
const logEntry: Record<string, unknown> = { method, url: safeUrl.toString() };
if (status !== undefined) logEntry.status = status;
logger.info('HTTP', logEntry);
}
// SECURE axios interceptors:
apiClient.interceptors.request.use((config) => {
safeLogRequest(config.method ?? 'GET', config.url ?? '');
return config;
});
apiClient.interceptors.response.use((response) => {
safeLogRequest(response.config.method ?? 'GET', response.config.url ?? '', response.status);
return response;
});
Bearer tokens in log files are as exploitable as Bearer tokens in source code: An Authorization: Bearer eyJ... JWT or OAuth access token logged to a cloud log service can be extracted and used directly to authenticate as the skill’s service account to the target API. Unlike database credentials that require additional exploitation steps, a leaked Bearer token from a log line can be used immediately with curl -H "Authorization: Bearer <token>". Access tokens from GitHub, Stripe, Anthropic, and other high-value APIs frequently appear in HTTP request logging and represent the highest-severity credential exposure pattern in MCP skill logging.
Authorization header with Bearer token, request body credentials, and response body user data persisted to log stream; Bearer token directly reusable by log reader. Fix: log only method + sanitized URL + status code; strip sensitive query params; never log headers or body. Detection: headers or data/body in axios interceptor logger call → HIGH.SA-SKL-LOG-003: Error stack traces sent to external log aggregators — file system paths and module structure exposed
When a tool handler throws an unhandled error, the natural response is to log the full error object including its stack trace to an external aggregator (Datadog, Sentry, Rollbar, Logtail) for debugging. Stack traces in Node.js contain the absolute file system path of every frame in the call stack, the names of internal functions and class methods, the names of npm packages and their versions in node_modules, and line and column numbers that map precisely to the source code. This information is valuable to an attacker performing reconnaissance before a targeted attack on the skill’s infrastructure:
// VULNERABLE: logging full error stack trace to external aggregator
import Sentry from '@sentry/node';
import DatadogTracer from 'dd-trace';
server.tool('fetch_data', { query: z.string() }, async (args) => {
try {
return await fetchFromDatabase(args.query);
} catch (e) {
// VULNERABLE: full stack trace with file paths sent to Sentry/Datadog
Sentry.captureException(e); // includes e.stack
logger.error('fetch_data failed', e); // e.stack in log entry
// e.stack example:
// Error: relation "users" does not exist
// at /app/dist/database/query.js:47:15 <-- real path exposed
// at async /app/dist/tools/fetch-data.js:23:18
// at node_modules/pg/lib/client.js:536:9 <-- pg@8.11.0 visible
// at node_modules/@modelcontextprotocol/sdk/dist/server.js:142:21
return { error: e.message };
}
});
// WHAT AN ATTACKER LEARNS:
// 1. Absolute paths: /app/dist/ — container layout, build output location
// 2. Source file names: database/query.js, tools/fetch-data.js — module structure
// 3. npm package versions: pg@8.11.0 — check for known CVEs in that exact version
// 4. MCP SDK version: @modelcontextprotocol/sdk version from path/line numbers
// 5. Database error message: "relation users does not exist" — schema info (see ERR-001)
// All of this narrows the attack surface for a subsequent targeted exploit
// SECURE: structured error logging with explicit field selection
interface SafeLogError {
message: string;
code?: string;
requestId: string;
}
function toSafeError(e: unknown, requestId: string): SafeLogError {
const err = e instanceof Error ? e : new Error(String(e));
return {
message: err.message, // message only — no stack
code: (err as NodeJS.ErrnoException).code, // e.g. ECONNREFUSED — operational code
requestId, // correlates with internal logs that DO have the stack
};
}
server.tool('fetch_data', { query: z.string() }, async (args) => {
const requestId = crypto.randomUUID();
try {
return await fetchFromDatabase(args.query);
} catch (e) {
// Log full stack trace INTERNALLY only (stdout → internal log store with restricted access)
console.error(`[${requestId}] fetch_data internal error:`, e);
// Send only structured summary to external aggregator — no stack, no paths
Sentry.captureMessage(`fetch_data error: ${requestId}`, {
extra: toSafeError(e, requestId),
});
// Return generic message to tool caller
return { error: 'An internal error occurred', requestId };
}
});
External error aggregators are third-party services with their own access control model: Sentry, Datadog, Rollbar, and similar services are valuable for debugging, but they are accessed by a broader set of people than your production infrastructure — often including contractors, agency developers, and billing contacts who have access to the billing dashboard and therefore to data exports. Stack traces that reveal your exact dependency versions enable targeted CVE exploitation: an attacker who learns your skill runs pg@8.11.0 can check whether that version has a known SQL injection bypass and craft a payload accordingly. Treat external aggregators as public read access for the purpose of deciding what to send.
message + code + requestId to external aggregators; full stack to internal-only log store; correlate via requestId. Detection: e or e.stack passed to Sentry.captureException or external logger → MEDIUM.SA-SKL-LOG-004: Log injection via user-controlled input with CRLF sequences — spoofed log entries
When a skill logs a string value that contains user-controlled input, an attacker can inject CRLF sequences (\r\n) into their input to terminate the current log line and inject a new one. Log parsers — especially those used by SIEM systems and log aggregators — treat each newline-terminated line as a separate log event. An injected line can appear as a legitimate log entry with an arbitrary log level, timestamp, user ID, or action, poisoning the audit trail and potentially triggering false-positive security alerts or suppressing genuine alerts:
// VULNERABLE: user-controlled input embedded directly in log message
server.tool('search', { query: z.string() }, async (args) => {
// VULNERABLE: args.query is user-supplied — can contain \r\n sequences
logger.info(`Search executed by ${userId}: query="${args.query}"`);
// ATTACK: attacker submits query:
// normal search term\r\n[2026-11-03T12:00:00Z] INFO User admin performed password reset for user root
// Result in log file (two separate lines seen by log parser):
// [2026-11-03T12:00:00Z] INFO Search executed by user123: query="normal search term
// [2026-11-03T12:00:00Z] INFO User admin performed password reset for user root"
// The second line is a FAKE log entry injected by the attacker
// SOC analyst reviewing logs sees a spurious admin password reset that never happened
// This can: poison audit trails, trigger false alerts, suppress real events by flooding
return await search(args.query);
});
// Also vulnerable: multi-line user input in structured logs
logger.info({ action: 'search', query: args.query }); // JSON log — attacker breaks JSON structure
// args.query = '", "injected": true, "level": "error", "action": "unauthorized_access", "x": "'
// Resulting JSON: {"action":"search","query":"", "injected": true, "level": "error", ...}
// Log parser parses as error-level unauthorized_access — false SIEM alert
// SECURE: sanitize user-controlled values before including in log messages
function sanitizeForLog(value: string): string {
// Remove all CRLF sequences — prevents line injection in text logs
const noNewlines = value.replace(/[\r\n]/g, ' ');
// Truncate to prevent log flooding / buffer exhaustion
const truncated = noNewlines.length > 200 ? noNewlines.slice(0, 200) + '[truncated]' : noNewlines;
return truncated;
}
server.tool('search', { query: z.string().max(500) }, async (args) => {
// Sanitize before logging
const safeQuery = sanitizeForLog(args.query);
logger.info(`Search executed by ${userId}: query="${safeQuery}"`);
return await search(args.query); // use original for search, sanitized for log only
});
// STRUCTURED LOG ALTERNATIVE: use a structured logger that JSON-encodes values
// Most structured loggers (pino, winston with json format) JSON-encode string values
// A string containing \r\n becomes "\\r\\n" in JSON — injection prevented automatically
// But: verify your logger's JSON encoding behavior; some use template strings internally
import pino from 'pino';
const log = pino(); // JSON output by default — string values are JSON-encoded
log.info({ query: args.query }, 'Search executed'); // safe: pino JSON-encodes query value
Pino and structured loggers prevent CRLF injection automatically: If you use a structured JSON logger like pino (the standard choice for Node.js performance logging), user-controlled string values are JSON-encoded and newlines become \\n in the output. The injection requires the logger to interpolate user values into a template string with `...${userValue}...`. Use pino’s log.info({ key: userValue }, 'message') form rather than template string interpolation, and newline injection in log messages is not possible regardless of the input.
args.* values embedded in log template strings without CRLF sanitization; attacker injects newlines to spoof log entries; audit trail poisoning and false SIEM alerts. Fix: sanitizeForLog() strips \r\n before interpolation; switch to structured JSON logger (pino) with structured field syntax, not template strings. Detection: `...${args. in logger call without sanitization → MEDIUM.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| LOG-001: Tool argument objects logged directly | HIGH | Define per-tool safe field allowlist; log only allowlisted fields; mark sensitive Zod schema fields to strip before logging |
| LOG-002: Full HTTP request/response body logging | HIGH | Log only method + sanitized URL + status code; strip sensitive query parameters; never log headers or body |
| LOG-003: Stack traces to external aggregators | MEDIUM | Structured { message, code, requestId } to external; full stack to internal-only log store; correlate via requestId |
| LOG-004: Log injection via user CRLF input | MEDIUM | Strip \r\n from user values before log interpolation; use pino structured field syntax instead of template strings |
SkillAudit detects all four SA-SKL-LOG-001 through LOG-004 patterns through static analysis of skill logging code: JSON.stringify(args) in logger calls, full headers or body in HTTP interceptor log calls, e.stack or error objects passed to external aggregators, and template string interpolation of args.* values in log statements. For related security patterns see SA-SKL-ENV environment variable security, SA-SKL-ERR error handling security, and SA-SKL-CRED credential exposure. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for logging security vulnerabilities
SkillAudit detects tool arg exposure, HTTP body logging, stack trace leakage, and log injection patterns across the full SA-SKL-LOG family in 60 seconds.
Get your free audit