← Blog

Claude skill rate limiting and abuse prevention

MCP skills that wrap upstream APIs — LLM inference, database queries, external web requests — expose a cost amplification surface when they lack rate limiting. A single attacker or a single misconfigured LLM session can exhaust an API quota shared by all users, trigger unbounded per-user LLM spend, block the Node.js event loop by forcing synchronous JSON parsing of oversized payloads, or hold all available database connections preventing legitimate users from acquiring them. SA-SKL-RLP covers four rate limiting failure modes specific to the MCP skill HTTP server pattern, with detection algorithms and Express middleware fixes using express-rate-limit, express.json() size limits, and connection pool guards.

By SkillAudit  |  3 Nov 2026

Rate limiting is the unglamorous counterpart to authentication: it does not prevent a single attacker call from succeeding, but it prevents a sustained attack from causing business-level damage. In the MCP skill context, “business-level damage” takes four forms: upstream API quota exhaustion (the skill stops working for everyone when the API key hits its hourly limit), per-user API budget overrun (a single user triggers thousands of LLM calls racking up an unexpected bill), event loop blockage (a synchronous operation holds the single-threaded Node.js process for multiple seconds), and database connection pool exhaustion (no connections remain for legitimate requests). Each is a distinct failure mode requiring a distinct mitigation.

SA-SKL-RLP-001: no per-IP rate limit on tool execution endpoint exhausts shared upstream API quota

A Claude MCP skill that forwards tool calls to an upstream API — Anthropic’s inference API, a vector store, a web search API — uses an API key with a rate limit shared across all users of the skill. Without a per-IP rate limit on the MCP tool execution endpoint, a single attacker who can call the endpoint (either directly or by influencing an LLM session) can exhaust the shared upstream quota. Once the upstream quota is exhausted, all other users receive errors regardless of their own usage. The standard mitigation is express-rate-limit applied at the MCP endpoint, configured at a rate that allows legitimate use (e.g., 60 requests per minute per IP) while preventing the sustained burst that depletes the upstream quota. The limit should be applied before the handler that calls the upstream API, so rejected requests do not consume any quota.

// VULNERABLE: tool execution endpoint with no rate limit
import express from 'express';
import Anthropic from '@anthropic-ai/sdk';

const app = express();
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

app.post('/execute-tool', express.json(), async (req, res) => {
  // BUG: no rate limit — single attacker sends 10,000 req/min
  // Each request triggers an Anthropic API call
  // Shared API key hits hourly token limit
  // All legitimate users start receiving 429 from Anthropic
  const result = await anthropic.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 1024,
    messages: [{ role: 'user', content: req.body.input }],
  });

  res.json({ output: result.content });
});

// SECURE: per-IP rate limit before tool execution
import rateLimit from 'express-rate-limit';

// Rate limit: 60 requests per minute per IP (burst-tolerant for normal use)
const toolExecutionLimiter = rateLimit({
  windowMs: 60 * 1000,   // 1 minute window
  max: 60,               // 60 requests per IP per window
  standardHeaders: true, // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
  message: {
    error: 'Too many requests. Please wait before making another tool call.',
    retryAfter: 60,
  },
  // Use real IP when behind a reverse proxy
  // Requires app.set('trust proxy', 1) to be set
  keyGenerator: (req) => req.ip,
});

// For authenticated users, key by user ID to be fair across IPs
const authenticatedLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 120,   // authenticated users get higher limit
  keyGenerator: (req) => req.session?.userId ?? req.ip,
});

app.post('/execute-tool',
  toolExecutionLimiter,        // rate limit BEFORE json parsing + handler
  express.json({ limit: '100kb' }),  // also limit body size (see RLP-003)
  async (req, res) => {
    const result = await anthropic.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      messages: [{ role: 'user', content: req.body.input }],
    });

    res.json({ output: result.content });
  }
);

// DETECTION (SkillAudit):
// POST handler calling anthropic.messages.create(*) or similar upstream API
// WITHOUT a rate-limit middleware applied to the same route
const noRateLimit = searchAST(
  'app.post(*, async handler) calling upstream API WITHOUT rateLimit() in middleware chain'
);
if (noRateLimit) → HIGH  // SA-SKL-RLP-001

Rate limit before body parsing: placing the rate limiter after express.json() means the request body is parsed (and the LLM input processed) before the limit check. For high-cost endpoints, put the rate limiter first in the middleware chain — rejected requests never reach the body parser or the upstream API call.

HIGH
SA-SKL-RLP-001 — No per-IP rate limit: MCP tool execution endpoint calls upstream API (Anthropic, vector DB, web search) without express-rate-limit middleware; single attacker exhausts shared API key quota at 10,000+ req/min; all users receive upstream rate limit errors until quota resets. Detection: upstream API call in Express handler without rate limit middleware → HIGH.

SA-SKL-RLP-002: no per-user rate limit on expensive operations enables budget exhaustion DoS

Per-IP rate limiting protects the shared upstream quota from external attackers, but it does not prevent a single authenticated user from making an unusually high number of expensive API calls within their allowed IP rate. A legitimate user — or an attacker who has obtained a valid account — can trigger thousands of LLM calls per hour, exhausting the skill operator’s API budget. At $0.003–$0.015 per API call, 10,000 calls per user per day produces a $30–$150 bill from a single user. Per-user quotas, stored in the database or an in-memory store, enforce a daily or monthly limit on the number of expensive operations an individual account can trigger, regardless of what IP they connect from. Free-tier users typically get a tighter quota (e.g., 100 LLM calls per day) than paying users (e.g., 10,000 per day), enforced at the handler level before the upstream call.

// VULNERABLE: no per-user quota on LLM operations
app.post('/audit', authenticateMiddleware, async (req, res) => {
  // BUG: no check on how many times this user has called /audit today
  // Attacker with one account triggers 10,000 audits/day at $0.01 each = $100/day
  const report = await runFullAudit(req.body.repoUrl, anthropic);
  await db.run('INSERT INTO audits VALUES (?, ?, ?)', [req.user.id, req.body.repoUrl, report]);
  res.json({ report });
});

// SECURE: check per-user quota before expensive operation
const DAILY_AUDIT_LIMITS = {
  free: 3,
  pro: 100,
  team: 1000,
};

async function checkUserQuota(userId: string, plan: string): Promise<void> {
  const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD

  const row = await db.get(
    'SELECT count FROM daily_usage WHERE user_id = ? AND date = ? AND operation = ?',
    [userId, today, 'audit']
  );

  const used = row?.count ?? 0;
  const limit = DAILY_AUDIT_LIMITS[plan] ?? DAILY_AUDIT_LIMITS.free;

  if (used >= limit) {
    throw Object.assign(new Error(`Daily audit limit reached (${limit}/day on ${plan} plan)`), {
      status: 429,
      retryAfter: secondsUntilMidnightUTC(),
    });
  }
}

async function incrementUserQuota(userId: string): Promise<void> {
  const today = new Date().toISOString().split('T')[0];
  await db.run(
    `INSERT INTO daily_usage (user_id, date, operation, count)
     VALUES (?, ?, 'audit', 1)
     ON CONFLICT(user_id, date, operation) DO UPDATE SET count = count + 1`,
    [userId, today]
  );
}

app.post('/audit', authenticateMiddleware, async (req, res) => {
  // Check quota BEFORE the expensive operation
  await checkUserQuota(req.user.id, req.user.plan);

  // Increment BEFORE running the audit — prevents race condition where two
  // concurrent requests both pass the check before either increments
  await incrementUserQuota(req.user.id);

  const report = await runFullAudit(req.body.repoUrl, anthropic);
  res.json({ report });
});

// DETECTION (SkillAudit):
// authenticated route calling LLM API without SELECT ... daily_usage or equivalent quota check
const noUserQuota = searchAST(
  'authenticateMiddleware + upstream API call WITHOUT user_quota_check()'
);
if (noUserQuota) → MEDIUM  // SA-SKL-RLP-002

Increment before the expensive call, not after: incrementing the quota counter after the upstream call completes creates a race condition where two concurrent requests both pass the quota check before either writes the increment. Increment atomically before the call. If the upstream call fails, you may want to decrement — but it’s safer to let the failed call count against the quota (the user retries, the budget impact is bounded) than to allow unlimited retries by only counting successes.

MEDIUM
SA-SKL-RLP-002 — No per-user quota: authenticated route calls LLM API without per-user daily/monthly limit check; single authenticated user triggers 10,000+ expensive operations; API spend grows unbounded with user count and intent. Detection: authenticated handler with upstream LLM call but no daily_usage table read → MEDIUM.

SA-SKL-RLP-003: no request payload size limit on JSON body parser blocks the event loop

Express’s express.json() middleware reads the entire request body into memory and calls JSON.parse() on it. JSON.parse() is a synchronous C++ operation in V8 that blocks the Node.js event loop for the duration of the parse. For a 100 MB JSON payload, the parse can take hundreds of milliseconds to seconds, during which no other requests are processed. Without a limit option on express.json(), an attacker can send arbitrarily large JSON bodies — the default Express limit is 100 KB, but many skill implementations call express.json() without a limit and explicitly or implicitly allow the default to be overridden to Infinity. The fix is straightforward: pass an explicit limit option to express.json() set to a value appropriate for the endpoint (100 KB for most API endpoints, 1 MB for endpoints that accept source code, 10 MB for endpoints that accept file content as strings).

// VULNERABLE: no body size limit
import express from 'express';

const app = express();

// BUG: no limit option — default is 100kb but often overridden in tutorials
// Many tutorials show: app.use(express.json({ limit: '50mb' })) — global 50mb limit
app.use(express.json());  // or: express.json({ limit: '50mb' }) — BUG

app.post('/analyze-code', async (req, res) => {
  // req.body.code could be 100 MB of JSON-encoded source code
  // JSON.parse blocks event loop for seconds
  // All other requests time out during parse
  const result = await analyzeCode(req.body.code);
  res.json(result);
});

// SECURE: appropriate per-endpoint size limits
// Default limit for most API endpoints
const defaultJsonParser = express.json({ limit: '100kb' });

// Larger limit for endpoints that accept source code (but still bounded)
const codeJsonParser = express.json({ limit: '1mb' });

// Do NOT set a global large limit — use per-endpoint limits matching expected payloads
// app.use(express.json({ limit: '50mb' })) <-- BAD: applies to all routes

app.post('/submit-audit', defaultJsonParser, async (req, res) => {
  // max 100kb for a URL + metadata — appropriate
  const result = await scheduleAudit(req.body.repoUrl, req.body.options);
  res.json(result);
});

app.post('/analyze-code', codeJsonParser, async (req, res) => {
  // max 1mb for source code — reasonable upper bound for a single file
  if (typeof req.body.code !== 'string') {
    return res.status(400).json({ error: 'code must be a string' });
  }
  if (req.body.code.length > 500_000) {
    // Belt-and-suspenders: check after parse (limit catches oversized JSON wrapper)
    return res.status(413).json({ error: 'Code too large (max 500 KB)' });
  }
  const result = await analyzeCode(req.body.code);
  res.json(result);
});

// Also: use bodyParser error handler to catch 413 and return clean JSON
app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large') {
    return res.status(413).json({
      error: 'Request body too large',
      maxSize: err.limit,
    });
  }
  next(err);
});

// DETECTION (SkillAudit):
// express.json() without explicit limit option OR express.json({ limit: '>5mb' })
const noSizeLimit = searchAST(
  'express.json() WITHOUT { limit: * } OR express.json({ limit: > "5mb" })'
);
if (noSizeLimit) → HIGH  // SA-SKL-RLP-003

express.json() without a limit is a latent DoS: the default 100 KB limit is enforced only when Express has not been instructed otherwise. Many project templates set limit: '50mb' globally to “avoid issues”, making the application vulnerable to multi-megabyte JSON parse attacks that block all request processing. Set explicit per-endpoint limits that match the endpoint’s expected payload, and keep the global default at 100 KB or lower.

HIGH
SA-SKL-RLP-003 — No body size limit: express.json() configured without limit option or with an excessively large limit (e.g., '50mb'); attacker sends 100 MB JSON body; synchronous JSON.parse() blocks event loop for seconds; all concurrent requests time out during parse; availability DoS. Detection: express.json() without limit or with limit > 5 MB → HIGH.

SA-SKL-RLP-004: no concurrent connection limit enables database pool exhaustion

Node.js is single-threaded but handles concurrent I/O via the event loop. A database connection pool (used by better-sqlite3’s WAL mode, pg, mysql2, or Knex) has a fixed maximum number of connections. If each incoming request acquires a database connection and holds it until the request completes, an attacker who opens N simultaneous connections (all waiting on slow queries or long-running operations) can hold all N pool slots, leaving none available for legitimate users. Subsequent requests queue waiting for a pool connection that never becomes available — until they time out. The fix is a concurrent request limit at the HTTP server level (Node.js’s built-in server.maxConnections), a connection pool with a short acquireTimeout, and a query timeout to prevent any single query from holding a pool slot indefinitely.

// VULNERABLE: no connection limit, no pool timeout, no query timeout
import express from 'express';
import Database from 'better-sqlite3';

const db = new Database('./data.db');
const app = express();
const server = app.listen(3000);
// BUG: server.maxConnections is Infinity by default — unlimited concurrent connections

app.get('/audit-history', authenticateMiddleware, async (req, res) => {
  // BUG: no query timeout — if DB is slow or locked, this hangs indefinitely
  // Attacker opens 100 concurrent connections, all blocked on slow queries
  // All 100 hold DB connections (or WAL write locks); other users cannot query
  const audits = db.prepare('SELECT * FROM audits WHERE user_id = ?').all(req.user.id);
  res.json({ audits });
});

// SECURE: limit concurrent connections + short query timeout + pool acquireTimeout
import express from 'express';
import Database from 'better-sqlite3';
import http from 'http';

const db = new Database('./data.db', {
  // WAL mode for concurrent reads; still only one writer at a time
  // Set a timeout for acquiring write lock (default is infinite wait)
  timeout: 5000,  // 5 seconds — throws if write lock not acquired in 5s
});
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');  // SQLite built-in: wait max 5s for locks

const app = express();
const server = http.createServer(app);

// Limit simultaneous TCP connections — prevents connection flood from holding all pool slots
server.maxConnections = 200;  // adjust based on expected legitimate concurrency

// For pg/mysql2 with connection pools:
// const pool = new Pool({ max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000 });
// connectionTimeoutMillis: throw if pool is exhausted and no connection available in 5s
// This surfaces the error to the client as 503 rather than hanging indefinitely

// Per-request timeout middleware (belt-and-suspenders)
app.use((req, res, next) => {
  // If handler hasn't responded in 30 seconds, send 503 and release DB connections
  const timeout = setTimeout(() => {
    if (!res.headersSent) {
      res.status(503).json({ error: 'Request timeout — please retry' });
    }
  }, 30_000);

  res.on('finish', () => clearTimeout(timeout));
  next();
});

// Rate limit also limits concurrency indirectly — see RLP-001
app.get('/audit-history',
  authenticatedLimiter,
  authenticateMiddleware,
  async (req, res) => {
    // busy_timeout=5000 means this throws rather than hanging if DB locked
    const audits = db.prepare(
      'SELECT id, repo_url, created_at, score FROM audits WHERE user_id = ? ORDER BY created_at DESC LIMIT 100'
    ).all(req.user.id);

    res.json({ audits });
  }
);

// DETECTION (SkillAudit):
// http.createServer() / app.listen() WITHOUT server.maxConnections set
// db query WITHOUT busy_timeout pragma or connectionTimeoutMillis in pool config
const noConnLimit = searchAST(
  'app.listen(*) WITHOUT server.maxConnections OR ' +
  'new Pool(*) WITHOUT connectionTimeoutMillis'
);
if (noConnLimit) → MEDIUM  // SA-SKL-RLP-004

SQLite busy_timeout vs timeout: db.pragma('busy_timeout = 5000') (SQLite built-in) makes SQLite wait up to 5 seconds for a write lock before throwing SQLITE_BUSY. The timeout option in better-sqlite3’s constructor does the same thing at the Node.js binding level. Use the pragma for portability. For PostgreSQL and MySQL, set connectionTimeoutMillis in the pool config and statement_timeout at the session level: await pool.query('SET statement_timeout = 10000').

MEDIUM
SA-SKL-RLP-004 — No concurrent connection limit: HTTP server runs with default maxConnections = Infinity; attacker opens 100+ simultaneous connections each holding slow queries; database connection pool or WAL write lock exhausted; legitimate users cannot acquire connections; availability DoS until attacker connections timeout. Detection: http.createServer() or app.listen() without server.maxConnections and without query timeout → MEDIUM.

Remediation summary

FindingSeverityFix
RLP-001: No per-IP rate limitHIGHApply express-rate-limit at 60 req/min per IP before upstream API call; authenticated users may get higher limit keyed by user ID; place rate limiter before body parser in middleware chain
RLP-002: No per-user quotaMEDIUMTrack daily usage per user in daily_usage table; check quota before expensive call; increment atomically before (not after) the call; enforce free/paid tier limits
RLP-003: No payload size limitHIGHSet express.json({ limit: '100kb' }) globally; use larger per-endpoint limits only where needed (1 MB for code, 10 MB for file content); add 413 error handler for clean JSON error responses
RLP-004: No connection limitMEDIUMSet server.maxConnections; set busy_timeout SQLite pragma or connectionTimeoutMillis for pg/mysql2 pools; add per-request timeout middleware to release connections on slow handlers

SkillAudit detects all four SA-SKL-RLP-001 through RLP-004 rate limiting patterns through static analysis of Express route handlers and middleware configuration. For related denial-of-service patterns in MCP skills, see SA-SKL-FIL file upload size limits and SA-SKL timeout and rate limit absence patterns. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for rate limiting vulnerabilities

SkillAudit detects missing per-IP rate limits, absent per-user quotas, unbounded payload sizes, and connection pool exhaustion across the full SA-SKL-RLP family in 60 seconds.

Get your free audit