← Blog

Claude skill authentication bypass

Authentication in Claude MCP skills answers one question: is this caller who they claim to be? The four patterns in SA-SKL-AUTH describe skills that check credentials without cryptographically verifying them. An API key passed in a header without a signature can be replayed by anyone who observes it once. A Bearer token in a URL query parameter appears in server access logs, browser history, and HTTP Referer headers sent to third-party analytics. A session token accepted from a query string can be shared in a link. And authentication logic placed in a tool description is documentation that any direct caller of the handler ignores entirely. Each pattern creates a distinct bypass path that an attacker can reach without knowing the real credential.

By SkillAudit  |  3 Nov 2026

MCP skills that expose privileged operations — reading user files, making API calls on behalf of users, modifying stored data — must authenticate callers before granting access. In the MCP architecture, the caller is typically the Claude host process, which invokes the skill on behalf of a specific user session. But the skill has no built-in guarantee that the tool call it receives comes from a legitimate session: a misconfigured MCP server, a prompt-injected LLM, or direct API access can all generate tool calls. Authentication must be implemented explicitly in the handler, with credentials that cannot be replayed, observed in transit, or bypassed by the caller controlling the authentication inputs.

SA-SKL-AUTH-001: X-Api-Key header accepted without HMAC signature validation

A common pattern in MCP skills that call upstream APIs is to require callers to provide an API key in a custom header: X-Api-Key: <value>. The skill checks that the header value matches a stored secret. This pattern has two weaknesses: first, the API key is a static bearer credential — anyone who observes it once (in a log, an error message, or a network capture) can replay it indefinitely. Second, the key identifies the caller but does not authenticate the specific request: an attacker who replays the same key can craft arbitrary tool calls that the skill accepts as authenticated. HMAC request signing — where the caller signs the full request body and timestamp with the shared secret, and the skill verifies the signature — prevents both replay and request forgery because each valid request produces a different signature that expires.

// VULNERABLE: static API key header — replayed by anyone who observes it
import { createServer } from '@anthropic-ai/mcp-sdk/server';

server.setToolHandler('read_private_data', async (args, context) => {
  const apiKey = context.headers?.['x-api-key'];

  // Static comparison: valid key is valid forever; any observer can replay
  if (apiKey !== process.env.MCP_API_KEY) {
    throw new Error('Unauthorized');
  }

  // Proceeds without checking whether this request was crafted by the legitimate caller
  return readPrivateData(args.userId);
});

// ALSO VULNERABLE: key passed in tool arguments rather than headers
// Attacker who reads the args (e.g. via a logging skill) gets the key
server.setToolHandler('read_private_data_v2', async (args) => {
  if (args.apiKey !== process.env.MCP_API_KEY) throw new Error('Unauthorized');
  return readPrivateData(args.userId);
});

// SECURE: HMAC-SHA256 request signing — signature covers body + timestamp
import crypto from 'crypto';

function verifyHmacRequest(
  body: string,
  timestamp: string,
  signature: string,
  secret: string
): boolean {
  // Replay protection: reject requests older than 5 minutes
  const requestAge = Date.now() - parseInt(timestamp, 10);
  if (requestAge > 5 * 60 * 1000) return false;

  // Signature covers timestamp + body — each request produces a unique signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  // Timing-safe comparison: prevents timing oracle attacks on the signature
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature, 'hex')
  );
}

server.setToolHandler('read_private_data_secure', async (args, context) => {
  const timestamp = context.headers?.['x-timestamp'] ?? '';
  const signature = context.headers?.['x-signature'] ?? '';
  const body = JSON.stringify(args);

  if (!verifyHmacRequest(body, timestamp, signature, process.env.MCP_SECRET)) {
    throw new Error('Unauthorized');
  }

  return readPrivateData(args.userId);
});

// DETECTION (SkillAudit):
// context.headers['x-api-key'] === process.env.* (static comparison, no HMAC)
// OR args.apiKey used in equality check without createHmac verification
const staticKey = searchAST(
  "headers['x-api-key'] !== process.env.* OR args.* === process.env.*"
);
if (staticKey.length > 0) → CRITICAL  // SA-SKL-AUTH-001

Static API keys are bearer credentials with no expiry: once an API key is observed — in a log line, an error trace, a network packet, or a tool call that the LLM echoes in its response — the attacker holds a permanent credential. HMAC signing converts the shared secret into a request authenticator: the secret is never transmitted, and each signed request expires within minutes. For MCP skills that gate privileged operations, HMAC signing is the minimum authentication standard. For production deployments, prefer short-lived signed JWTs (RS256, expiry of 15 minutes) over long-lived shared secrets.

CRITICAL
SA-SKL-AUTH-001 — Unsigned API key header: static X-Api-Key header without HMAC signature validation; key is a static bearer credential — once observed, valid forever for arbitrary requests. Detection: headers['x-api-key'] !== process.env.* without createHmac verification → CRITICAL.

SA-SKL-AUTH-002: Bearer token passed as query parameter rather than Authorization header

OAuth Bearer tokens and session tokens are designed to be transmitted in the HTTP Authorization header: Authorization: Bearer <token>. The Authorization header is excluded from server access logs by default in most web servers and reverse proxies. It is not included in the HTTP Referer header that browsers send to third-party resources. And TLS protects the header value in transit, so it does not appear in network captures. Query parameters have none of these protections: a Bearer token in ?token=<value> is logged in every access log entry, appears in the Referer header sent to any third-party script on the page, and may be stored in browser history, CDN access logs, proxy logs, and any server that receives a request from the page. MCP skills that accept authentication tokens in URL query parameters expose every authenticated session to passive observation by anyone with access to any of these log sources.

// VULNERABLE: Bearer token in query parameter — logged everywhere
import express from 'express';
const app = express();

// MCP skill that exposes an HTTP endpoint accepting token in query string
app.get('/mcp/tool/get_report', async (req, res) => {
  const token = req.query.token as string;
  // This token now appears in: Nginx/Caddy access logs, browser history,
  // analytics referer strings, CDN logs, and any server the page links to

  if (!token || !isValidToken(token)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const userId = getUserFromToken(token);
  res.json(await getReport(userId));
});

// The access log entry looks like:
// GET /mcp/tool/get_report?token=eyJhbGci... 200 [timestamp] [ip]
// ↑ Token fully exposed in log — any log reader gets a valid credential

// ALSO VULNERABLE: passing token as a URL parameter in fetch() calls
// that appear in tool arguments passed by the LLM
async function fetchWithTokenInUrl(resourceId: string, token: string) {
  const url = `https://api.internal/resource/${resourceId}?access_token=${token}`;
  // ↑ token appears in server logs of api.internal AND any proxy in between
  return fetch(url);
}

// SECURE: token in Authorization header — excluded from default log formats
app.get('/mcp/tool/get_report', async (req, res) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const token = authHeader.slice(7);  // strip 'Bearer '
  if (!isValidToken(token)) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  const userId = getUserFromToken(token);
  res.json(await getReport(userId));
});

// SECURE: for MCP tool handlers using fetch(), pass token in header
async function fetchWithAuthHeader(resourceId: string, token: string) {
  return fetch(`https://api.internal/resource/${resourceId}`, {
    headers: { 'Authorization': `Bearer ${token}` }
    // ↑ header not logged by default — not in Referer, not in browser history
  });
}

// DETECTION (SkillAudit):
// req.query.token | req.query.access_token | req.query.api_key used for auth
// OR URL construction with token/api_key/access_token in query string
const tokenInQuery = searchAST(
  'req.query.token | req.query.access_token | req.query.api_key ' +
  'OR `?token=${*}` | `?access_token=${*}` | `?api_key=${*}`'
);
if (tokenInQuery.length > 0) → HIGH  // SA-SKL-AUTH-002

Logs are not the only exposure path for query-parameter tokens: in addition to server access logs, tokens in URLs are propagated via the HTTP Referer header to every third-party resource loaded by a page that includes the URL (analytics scripts, fonts, image CDNs). On mobile apps, OS-level analytics may log full URLs including query parameters. Reverse proxies that cache responses may store query parameters in their cache key metadata. The Authorization header is the only standard mechanism that avoids all these exposure paths simultaneously.

HIGH
SA-SKL-AUTH-002 — Bearer token in query parameter: authentication token in ?token= query string appears in server access logs, HTTP Referer, browser history, CDN logs, and proxy logs. Detection: req.query.token used for authentication → HIGH.

SA-SKL-AUTH-003: session token accepted from query string without HttpOnly cookie binding

Session tokens should be bound to the transport mechanism that provides them: a session established via a browser cookie should be validated against a cookie, not re-accepted via a query parameter. When a skill accepts a session token from both a cookie and a query parameter, an attacker can share a URL containing the token and have any recipient access the authenticated session. More specifically, skills that accept session tokens in query strings do not benefit from the browser’s cookie isolation model: HttpOnly cookies are inaccessible to JavaScript (preventing XSS theft), SameSite=Strict cookies prevent CSRF, and Secure cookies are only transmitted over HTTPS. A query-parameter session token has none of these protections — it can be extracted by JavaScript, sent cross-origin, and transmitted over HTTP.

// VULNERABLE: session token accepted from query string or cookie — caller chooses
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

app.get('/mcp/session/data', async (req, res) => {
  // Accepts token from EITHER cookie OR query string — caller controls which path
  const sessionToken =
    req.cookies.session_token ||   // cookie (protected by HttpOnly, SameSite, Secure)
    req.query.session_token;        // query string (none of those protections)

  // Attacker shares: /mcp/session/data?session_token=<victim_token>
  // Any recipient, any origin can make this request and access the session

  if (!sessionToken || !isValidSession(sessionToken)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const userId = getSessionUser(sessionToken);
  res.json(await getUserData(userId));
});

// SECURE: accept session token from cookie ONLY
app.get('/mcp/session/data', async (req, res) => {
  const sessionToken = req.cookies.session_token;  // cookie only — not from query

  if (!sessionToken || !isValidSession(sessionToken)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const userId = getSessionUser(sessionToken);
  res.json(await getUserData(userId));
});

// SECURE: cookie configuration that maximises isolation
res.cookie('session_token', token, {
  httpOnly: true,    // inaccessible to JavaScript — prevents XSS theft
  sameSite: 'strict', // only sent for same-site requests — prevents CSRF
  secure: true,      // only sent over HTTPS — prevents plaintext capture
  maxAge: 3600_000,  // 1-hour expiry — limits window for token theft
  path: '/mcp/'      // scoped to MCP endpoints only — not sent to /blog/, /seo/, etc.
});

// DETECTION (SkillAudit):
// req.cookies.session_token || req.query.session_token (fallback accepts query)
// OR req.query.session_token | req.query.sid used for session validation
const sessionInQuery = searchAST(
  'req.query.session_token | req.query.sid | req.query.sess ' +
  'OR req.cookies.* || req.query.* (cookie-or-query fallback)'
);
if (sessionInQuery.length > 0) → HIGH  // SA-SKL-AUTH-003

Token binding principle: a session token should only be accepted via the same transport that was used to establish the session. If the session was created by a POST to /login that sets an HttpOnly cookie, subsequent requests must authenticate via that cookie — not via a header the client could set arbitrarily, and never via a query parameter. This binding prevents session tokens from being used across transport mechanisms and limits the impact of token theft to the specific transport it was designed for.

HIGH
SA-SKL-AUTH-003 — Session token in query string: req.query.session_token accepted as authentication bypasses HttpOnly/SameSite/Secure cookie protections; token shareable in URL, accessible to JavaScript, sendable cross-origin. Detection: req.query.session_token or cookie-or-query fallback → HIGH.

SA-SKL-AUTH-004: authentication check placed in tool description text instead of handler code

MCP tool descriptions are metadata strings that the LLM reads to decide whether and how to invoke a tool. They are not security controls. A tool description that says “ADMIN ONLY: do not call this without an admin session” is instruction to the LLM — it has no effect on any caller that bypasses the LLM and calls the MCP handler directly. In MCP, skills expose tool endpoints that can be called by any process with access to the MCP transport. A security researcher, an automated scanner, or an attacker who has compromised another tool in the same skill can call any tool handler regardless of what its description says. Authentication — checking credentials, validating tokens, verifying session state — must be implemented in the handler function body.

// VULNERABLE: "authentication" in tool description — no handler enforcement
const ADMIN_TOOL_DEFINITION = {
  name: 'delete_all_user_data',
  description: `ADMIN ONLY: Deletes all data for a user account.
    IMPORTANT: Do not call this tool without confirming admin authentication first.
    Only authorized administrators should invoke this function.`,
  inputSchema: {
    type: 'object',
    properties: { userId: { type: 'string' } },
    required: ['userId']
  }
};

// Handler has no auth check — any caller bypassing the LLM can call this directly
server.setToolHandler('delete_all_user_data', async (args) => {
  // The description said "admin only" — but the handler doesn't check anything
  await deleteUserData(args.userId);  // ← executes for ANY caller
  return { success: true, deleted: args.userId };
});

// ALSO VULNERABLE: validation in tool list endpoint but not in handler
// Listing endpoint returns only admin tools if caller is admin
// But the handler itself accepts calls from anyone who knows the tool name
app.get('/mcp/tools', (req, res) => {
  const isAdmin = checkAdminSession(req);
  const tools = isAdmin ? allTools : publicTools;  // admin tools hidden from non-admins in list
  res.json(tools);  // ← attacker who knows the tool name can call it directly without listing
});

// SECURE: authentication enforced in every handler that requires it
server.setToolHandler('delete_all_user_data', async (args, context) => {
  // Authentication in handler — independent of what the description says or the LLM does
  const sessionToken = context.headers?.['authorization']?.replace('Bearer ', '');
  if (!sessionToken) throw new Error('Authentication required');

  const session = await validateSession(sessionToken);
  if (!session) throw new Error('Invalid session');

  // Authorization: authenticated user must also be an admin
  if (!session.roles.includes('admin')) {
    throw new Error('Admin access required');
  }

  // Audit log: privileged operations should record who authorized them
  await auditLog.write({
    action: 'delete_user_data',
    userId: args.userId,
    authorizedBy: session.userId,
    timestamp: new Date().toISOString()
  });

  await deleteUserData(args.userId);
  return { success: true, deleted: args.userId };
});

// DETECTION (SkillAudit):
// Tool handler body contains no authentication check (no session validation,
// no token check, no header read) when the tool description contains
// access-control language ("admin", "only", "authorized", "authenticated")
const authInDescOnly = searchAST(
  'tool.description includes /admin|only|authorized|authenticated/i ' +
  'AND handler body has no session/token validation'
);
if (authInDescOnly.length > 0) → HIGH  // SA-SKL-AUTH-004

Tool descriptions are LLM guidance, not security enforcement: the LLM is one path through which a tool handler gets called. A malicious or prompt-injected LLM, a direct API caller, an automated scanner, or a peer skill in the same MCP server can all invoke handlers without the LLM ever reading the description. Treat every tool handler as a public endpoint: implement authentication at the handler entry point, regardless of what the description says. An admin tool without handler-level auth is an unauthenticated admin tool with a disclaimer comment.

HIGH
SA-SKL-AUTH-004 — Auth in tool description: access control language in tool description with no authentication check in handler body — any direct caller bypasses the LLM and executes the handler without credentials. Detection: description contains access-control keywords, handler has no session/token validation → HIGH.

Remediation summary

FindingSeverityFix
AUTH-001: Unsigned API key headerCRITICALReplace static key comparison with HMAC-SHA256 request signing; include timestamp in signed payload for replay protection; use crypto.timingSafeEqual() for comparison
AUTH-002: Bearer token in query parameterHIGHMove token to Authorization header (Bearer <token>); audit all fetch() calls that include token in URL
AUTH-003: Session token in query stringHIGHAccept session token from cookie only; configure cookie with httpOnly: true, sameSite: 'strict', secure: true
AUTH-004: Auth in tool descriptionHIGHAdd explicit authentication check at handler entry point; validate session token from request context before any privileged operation

SkillAudit detects all four SA-SKL-AUTH-001 through AUTH-004 authentication bypass patterns through AST-based analysis of tool handler code and request handling logic. Related families: SA-SKL-AUTH-005 through AUTH-008 (authorization bypass) covers privilege escalation patterns that build on successful authentication. See the complete 15-family SA-SKL attack surface reference for the full taxonomy.

Scan your Claude skill for authentication vulnerabilities

SkillAudit detects unsigned API keys, tokens in query parameters, session token bypass, and auth-in-description patterns across the full SA-SKL-AUTH family in 60 seconds.

Get your free audit