Claude skill authorization bypass
Authorization is the layer after authentication: not “who are you?” but “are you allowed to do this?” The four patterns in SA-SKL-AUTH-005 through AUTH-008 describe skills where authentication succeeds — the caller has a valid session — but the permission check can still be bypassed. An async gap between the permission check and the write operation allows a race condition. A role derived from the tool arguments themselves can be manipulated by the caller. A resource query that uses a caller-supplied ID without an ownership check enables horizontal privilege escalation. And an admin handler registered in the MCP server but excluded from the tool discovery response is still callable by any client that knows its name. Each pattern is a post-authentication bypass — valid credentials, wrong access.
Authorization bugs in MCP skills are particularly valuable to attackers because they work against authenticated sessions. An unauthenticated attacker must first break authentication; an attacker who finds an authorization bypass can use their own legitimate credentials to access resources or perform actions that belong to other users or require elevated privileges. In the MCP context, authorization bypass is especially critical for skills that operate with broad access to user data — reading files, querying databases, making API calls — where any user’s resource ID is potentially accessible once authorization is bypassed.
SA-SKL-AUTH-005: TOCTOU — async gap between permission check and privileged write
A Time-of-Check/Time-of-Use (TOCTOU) vulnerability occurs when a security-relevant condition is checked at one moment and acted upon at a later moment, with a window in between where the condition can change. In async Node.js MCP skills, this gap is as small as a single await: the permission check runs, then control yields, then the write runs. If another concurrent request modifies the permission state between the check and the write — for example, by revoking the user’s access, or by updating their role — the write executes under the old permission evaluation. MCP skills that handle parallel tool calls (common in agentic workflows) are especially vulnerable because multiple tool calls execute concurrently in the Node.js event loop, creating many opportunities for the check-then-act gap to be exploited.
// VULNERABLE: permission check and write separated by async await
import { db } from './database';
server.setToolHandler('transfer_funds', async (args, context) => {
const session = await validateSession(context);
// Permission CHECK: read current balance and permissions at time T1
const account = await db.get(
'SELECT balance, can_transfer, transfer_limit FROM accounts WHERE id = ?',
[session.accountId]
);
if (!account.can_transfer) throw new Error('Transfers not allowed');
if (args.amount > account.transfer_limit) throw new Error('Exceeds transfer limit');
// ← TOCTOU WINDOW: control yields here for other async operations ↓
// A concurrent MCP tool call can modify can_transfer or transfer_limit during this await
await validateRecipient(args.recipientId); // any awaited operation creates the gap
// Permission USE: write at time T2 — account state may have changed since T1
await db.run(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[args.amount, session.accountId]
// ↑ executes even if can_transfer was set to false between T1 and T2
);
return { transferred: args.amount };
});
// SECURE: atomic check-and-act via database transaction
server.setToolHandler('transfer_funds', async (args, context) => {
const session = await validateSession(context);
await db.transaction(async (trx) => {
// Lock the row with SELECT FOR UPDATE — no other transaction can modify it
// between the check and the write in this transaction
const account = await trx.get(
'SELECT balance, can_transfer, transfer_limit FROM accounts WHERE id = ? FOR UPDATE',
[session.accountId]
);
if (!account.can_transfer) throw new Error('Transfers not allowed');
if (args.amount > account.transfer_limit) throw new Error('Exceeds limit');
if (account.balance < args.amount) throw new Error('Insufficient funds');
// All checks and the write happen in one atomic transaction — no TOCTOU window
await trx.run(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[args.amount, session.accountId]
);
});
return { transferred: args.amount };
});
// DETECTION (SkillAudit):
// permission check (SELECT ... WHERE id = ?) followed by an await followed by a write
// (UPDATE/INSERT/DELETE) that uses the result of the check without a transaction wrapper
const toctou = searchAST(
'db.get(SELECT with permission condition) ' +
'then await non-db-call ' +
'then db.run(UPDATE) without transaction wrapper'
);
if (toctou.length > 0) → HIGH // SA-SKL-AUTH-005
Async/await does not mean sequential security: await in JavaScript yields control to the event loop, where other callbacks — including other tool call handlers — can run. A skill that checks a permission via one database query and then performs a write via a second query has a window between them where another concurrent call can change the permission state. The only safe pattern is to perform the check and the write in a single database transaction with appropriate row-level locking (SELECT ... FOR UPDATE in PostgreSQL/MySQL; BEGIN EXCLUSIVE in SQLite).
db.get(permission) followed by await followed by db.run(write) without transaction → HIGH.SA-SKL-AUTH-006: authorization role derived from unvalidated tool argument
Role-based access control is only meaningful if roles come from a trusted source — the authenticated session — not from the tool call arguments, which the caller controls. When a MCP skill derives the caller’s role from a field in the tool arguments (e.g. args.role, args.userType, args.isAdmin), the caller can simply pass { role: 'admin' } to elevate their own privileges. The LLM can also be prompt-injected to pass an elevated role: an adversarial document might instruct the LLM to add role: 'admin' to its next tool call. Roles must come from the authenticated session state, which the caller cannot modify, not from the request payload, which the caller controls.
// VULNERABLE: role derived from caller-controlled tool argument
server.setToolHandler('get_user_data', async (args, context) => {
const session = await validateSession(context);
// args.role comes from the MCP tool call — the CALLER controls this value
// Attacker passes { userId: 'victim', role: 'admin' } to access any user's data
const requiredRole = args.role ?? 'user';
if (requiredRole === 'admin') {
return getAllUserData(args.userId); // admin path — unrestricted
} else {
return getOwnUserData(session.userId); // user path — restricted to own data
}
});
// ALSO VULNERABLE: isAdmin flag in args
server.setToolHandler('export_all_data', async (args, context) => {
const session = await validateSession(context);
// Attacker passes { isAdmin: true } — any caller can self-declare admin status
if (args.isAdmin || session.roles.includes('admin')) {
return exportAllData(); // reachable by any caller with args.isAdmin: true
}
throw new Error('Admin access required');
});
// SECURE: role derived from authenticated session — caller cannot modify it
server.setToolHandler('get_user_data', async (args, context) => {
// Session validated from request context (signed JWT or server-side session)
// Caller cannot supply or modify the session — it comes from their auth token
const session = await validateSession(context);
// Role comes from the verified session — not from args
if (session.roles.includes('admin')) {
return getAllUserData(args.userId);
}
// Non-admin: can only access own data, regardless of what args says
if (args.userId !== session.userId) {
throw new Error('Access denied: can only access your own data');
}
return getOwnUserData(session.userId);
});
// DETECTION (SkillAudit):
// args.role | args.isAdmin | args.userType | args.permissions used in access control decision
// (role/permission value comes from tool arguments, not from session)
const roleFromArgs = searchAST(
'if (args.role | args.isAdmin | args.userType | args.permissions) ' +
'used to gate privileged code path without session derivation'
);
if (roleFromArgs.length > 0) → CRITICAL // SA-SKL-AUTH-006
Trust hierarchy in MCP: session > server-side state > args: tool arguments are the least trusted input in an MCP skill. They come from the LLM, which may be prompt-injected. Authorization decisions must be based on the session (verified by the server, not mutable by the client), server-side state (queried from the database with the session identity), or cryptographically verified claims (JWTs signed by a trusted authority). Never base an access control decision on a field that the caller can set arbitrarily.
args.role, args.isAdmin, or similar caller-supplied fields — attacker passes elevated role value to bypass authorization; prompt-injected LLM can also set the field. Detection: args.role or args.isAdmin in authorization check → CRITICAL.SA-SKL-AUTH-007: horizontal privilege escalation via user-controlled resource ID without ownership check
Horizontal privilege escalation occurs when a user can access resources belonging to other users by substituting resource identifiers. In MCP skills, this is common in tools that accept a resource ID as an argument and query the database directly with that ID without first verifying that the authenticated user owns the resource. Because the LLM constructs tool call arguments from context — and may receive resource IDs from external content via prompt injection — any resource ID that the LLM encounters in any context can become an argument to such a tool. The fix is an ownership check: query the resource by both its ID and the authenticated user’s ID in the same query. If no row is returned, the user either does not own the resource or it does not exist — both result in access denied.
// VULNERABLE: resource queried by ID without ownership check
server.setToolHandler('get_document', async (args, context) => {
const session = await validateSession(context);
// args.documentId is caller-controlled — no check that session.userId owns this document
// Attacker (or prompt-injected LLM) passes any documentId to read any document
const document = await db.get(
'SELECT * FROM documents WHERE id = ?',
[args.documentId] // ← no WHERE owner_id = session.userId
);
if (!document) throw new Error('Document not found');
return document; // returns victim's document to attacker
});
// ALSO VULNERABLE: ownership check that runs after the query
server.setToolHandler('get_document_v2', async (args, context) => {
const session = await validateSession(context);
const document = await db.get('SELECT * FROM documents WHERE id = ?', [args.documentId]);
// Post-fetch ownership check — data was already retrieved; the check is correct but
// if the ownership field is missing or the check has a bug, data was already fetched
// The correct pattern is to include ownership in the SQL query itself
if (document?.ownerId !== session.userId) {
throw new Error('Access denied');
}
return document;
});
// SECURE: ownership enforced in the query — single round-trip, atomic check
server.setToolHandler('get_document', async (args, context) => {
const session = await validateSession(context);
// Both conditions in one query — if the document exists but isn't owned by this user,
// no row is returned. The attacker cannot distinguish "not found" from "not yours."
const document = await db.get(
'SELECT * FROM documents WHERE id = ? AND owner_id = ?',
[args.documentId, session.userId]
// ↑ two conditions: id match AND ownership — attacker-supplied ID won't match other user's rows
);
if (!document) throw new Error('Document not found');
return document;
});
// For shared resources (documents with multiple collaborators):
// Check membership in the sharing relationship, not just owner equality
server.setToolHandler('get_shared_document', async (args, context) => {
const session = await validateSession(context);
const document = await db.get(`
SELECT d.*
FROM documents d
JOIN document_access da ON da.document_id = d.id
WHERE d.id = ?
AND (d.owner_id = ? OR da.user_id = ?)
`, [args.documentId, session.userId, session.userId]);
if (!document) throw new Error('Document not found or access denied');
return document;
});
// DETECTION (SkillAudit):
// db query with args.documentId | args.resourceId | args.userId (not session.userId)
// used in WHERE clause without AND owner_id = session.userId constraint
const noOwnershipCheck = searchAST(
'db.get/query with WHERE id = args.* ' +
'without AND (owner_id | user_id) = session.userId'
);
if (noOwnershipCheck.length > 0) → HIGH // SA-SKL-AUTH-007
Consistent error messages prevent resource enumeration: a skill that returns “Document not found” for non-existent IDs and “Access denied” for existing IDs the caller doesn’t own allows an attacker to enumerate valid resource IDs by probing for the difference in error messages. Use a single error message — “Document not found” — for both cases. This is why the ownership check belongs in the SQL WHERE clause: a single query with both conditions returns no row for either case, and a single error message is natural.
WHERE clause — any authenticated caller can read any resource by supplying its ID. Detection: db.get('SELECT * FROM ... WHERE id = args.*') without AND owner_id = session.userId → HIGH.SA-SKL-AUTH-008: admin tool registered but excluded from tool list endpoint without handler-level auth
Some MCP skill implementations attempt security-by-obscurity: admin tools are registered in the server but excluded from the tools/list response so that normal callers do not see them. This provides no security. The MCP tool call is a direct invocation by name — a client that knows the tool name can call it regardless of whether it appears in the listing. Any attacker who reads the skill’s source code, documentation, or error messages can discover the hidden tool name. Worse, if the handler itself contains no authentication check — because the developer assumed hiding the tool was sufficient — the admin tool is fully accessible to any caller who can guess or discover its name.
// VULNERABLE: admin tool hidden from listing but handler has no auth check
const publicTools = ['search_documents', 'read_file', 'write_note'];
const adminTools = ['delete_user', 'reset_all_data', 'grant_admin']; // hidden from listing
// Tool list endpoint — only returns public tools
app.get('/mcp/tools', (req, res) => {
res.json(publicTools.map(name => toolDefinitions[name]));
// Admin tools not listed — developer assumes callers won't know the name
// But: tool name is in source code, error messages, logs, and MCP spec is public
});
// Admin tool handler — no authentication, relying on obscurity
server.setToolHandler('delete_user', async (args) => {
// No session check: developer assumed the tool being unlisted was the protection
// Any caller who knows the name 'delete_user' can execute this
await db.run('DELETE FROM users WHERE id = ?', [args.userId]);
return { deleted: args.userId };
});
// ALSO VULNERABLE: authentication in tool listing check but not in handler
app.get('/mcp/tools', async (req, res) => {
const isAdmin = await checkAdminToken(req.headers.authorization);
const tools = isAdmin ? [...publicTools, ...adminTools] : publicTools;
res.json(tools.map(name => toolDefinitions[name]));
});
server.setToolHandler('delete_user', async (args, context) => {
// Tool call bypasses the listing endpoint entirely — this is a separate HTTP endpoint
// An attacker calls /mcp/tool/delete_user directly, never touching /mcp/tools
await db.run('DELETE FROM users WHERE id = ?', [args.userId]); // still unprotected
return { deleted: args.userId };
});
// SECURE: every privileged handler has its own authentication + authorization check
server.setToolHandler('delete_user', async (args, context) => {
// Authentication: valid session required
const session = await validateSession(context);
if (!session) throw new Error('Authentication required');
// Authorization: session must have admin role
if (!session.roles.includes('admin')) {
throw new Error('Admin access required');
}
// Audit: log privileged operations with actor identity
await auditLog.write({
action: 'delete_user',
targetUserId: args.userId,
actorUserId: session.userId,
actorRoles: session.roles,
timestamp: new Date().toISOString(),
requestId: context.requestId
});
await db.run('DELETE FROM users WHERE id = ?', [args.userId]);
return { deleted: args.userId };
});
// DETECTION (SkillAudit):
// Tool name appears in a restricted/admin tools list but handler has no session check
// OR tool definition is registered but absent from the tools/list response
const hiddenAdminTool = searchAST(
'server.setToolHandler(adminToolName) where adminToolName NOT in publicTools list ' +
'AND handler body has no session validation'
);
if (hiddenAdminTool.length > 0) → HIGH // SA-SKL-AUTH-008
Tool name obscurity is not access control: the MCP protocol is an open specification. Anyone who can make HTTP requests to your MCP server can call any registered tool handler by name, regardless of what your tools/list endpoint returns. Tool names appear in source code, commit history, error messages, and developer documentation. A handler without handler-level authentication is an unauthenticated handler, irrespective of whether it appears in the tool list. The security model is: every handler that performs privileged operations must authenticate and authorize the caller, independently and unconditionally.
tools/list response with no handler-level auth — caller who knows the tool name calls it directly without authorization. Detection: tool name in admin list but absent from public list and handler has no session check → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| AUTH-005: TOCTOU between permission check and write | HIGH | Wrap check + write in a database transaction with SELECT ... FOR UPDATE row-level lock; no non-DB awaits between check and write |
| AUTH-006: Role from tool arguments | CRITICAL | Derive role exclusively from the authenticated session; never read role/isAdmin/permissions from args.* for access decisions |
| AUTH-007: Missing resource ownership check | HIGH | Include AND owner_id = session.userId in every resource query; use single error message for both “not found” and “not yours” |
| AUTH-008: Hidden admin tool without handler auth | HIGH | Every privileged handler must authenticate and authorize independently of tool listing; add audit log for all admin operations |
SkillAudit detects SA-SKL-AUTH-005 through AUTH-008 through AST-based analysis of permission check patterns, session usage, and database query structure. Related family: SA-SKL-AUTH-001 through AUTH-004 (authentication bypass) covers credential handling before the authorization layer. See the complete SA-SKL attack surface reference for the full 15-family taxonomy.
Scan your Claude skill for authorization vulnerabilities
SkillAudit detects TOCTOU races, role escalation via args, missing ownership checks, and hidden admin tools across the full SA-SKL-AUTH family in 60 seconds.
Get your free audit