← Blog

MCP server pagination cursor security

The MCP 2025-03-26 specification defines cursor-based pagination for resources/list, tools/list, and prompts/list. Servers return a nextCursor opaque token; clients pass it back in subsequent list requests. Because the cursor’s internal structure is not specified, implementations vary widely — and most encode server-side state (offsets, database row IDs, timestamps) in a way that enables four attack classes: cursor predictability for cross-tenant jumping, cursor forgery for out-of-bounds access, cursor poisoning for parse-error information leakage, and cursor amplification for full resource enumeration.

By SkillAudit  |  31 Oct 2026

MCP cursors are described in the specification as “opaque tokens” — the client must not interpret them. But the server must interpret them, and that interpretation is where the vulnerability lies. A cursor that encodes { offset: 20, userId: "usr_123" } as Base64 is trivially decodeable. A cursor that is simply the last row’s auto-increment ID is trivially guessable. Even a UUID cursor is vulnerable if pagination state is stored server-side without session binding. The four SA-MCP-PAG patterns cover the complete attack surface of cursor-based pagination in MCP servers.

SA-MCP-PAG-001: predictable cursors allow cross-tenant page jumping in multi-user servers

MCP servers that generate pagination cursors from sequential integers, timestamps, database row IDs, or deterministic hashes allow an attacker to predict valid cursors for other users’ pagination sessions. In a multi-tenant hosted MCP server (e.g., a file resource server where each user has their own resource namespace), predictable cursors let an attacker walk through another user’s paginated resource list by guessing cursor values derived from their session’s pagination state.

// VULNERABLE: cursor is auto-increment row ID — trivially predictable
server.setRequestHandler('resources/list', async (req, context) => {
  const cursor = req.params?.cursor;
  const lastId = cursor ? parseInt(cursor, 10) : 0;  // cursor is just a number string

  const rows = await db.query(
    'SELECT * FROM resources WHERE owner_id = ? AND id > ? ORDER BY id LIMIT 20',
    [context.userId, lastId]
  );
  // cursor = "20", "40", "60" ... attacker increments by 20 to walk any user's resources
  return {
    resources: rows.map(toResource),
    nextCursor: rows.length === 20 ? String(rows[rows.length-1].id) : undefined
  };
});

// ALSO VULNERABLE: cursor encodes userId + offset as Base64 JSON
const cursor = Buffer.from(JSON.stringify({ userId: context.userId, offset: 20 })).toString('base64');
// Attacker decodes: { userId: "usr_123", offset: 20 }
// Re-encodes with: { userId: "usr_456", offset: 0 } → walks usr_456's resources

// ALSO VULNERABLE: cursor is a timestamp — predictable to ±1 second
const cursor = Buffer.from(String(Date.now())).toString('base64');

// SECURE: cursor is an HMAC-signed, session-bound token
const CURSOR_SECRET = process.env.CURSOR_HMAC_SECRET; // 256-bit secret

function encodeCursor(sessionId, state) {
  const payload = JSON.stringify({ sessionId, state, iat: Date.now() });
  const sig = crypto.createHmac('sha256', CURSOR_SECRET)
    .update(payload).digest('base64url');
  return Buffer.from(payload).toString('base64url') + '.' + sig;
}

function decodeCursor(token, sessionId) {
  const [payloadB64, sig] = token.split('.');
  const payload = Buffer.from(payloadB64, 'base64url').toString();
  const expectedSig = crypto.createHmac('sha256', CURSOR_SECRET)
    .update(payload).digest('base64url');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
    throw new McpError(ErrorCode.InvalidParams, 'Invalid cursor');
  }
  const data = JSON.parse(payload);
  if (data.sessionId !== sessionId) {  // ← session binding
    throw new McpError(ErrorCode.InvalidParams, 'Cursor not valid for this session');
  }
  return data.state;
}

// DETECTION (SkillAudit):
// Find cursor generation: is nextCursor derived from an integer, timestamp, or row ID?
const cursorGen = findCursorGenerationExpression();
if (isSequentialInteger(cursorGen)) → CRITICAL // SA-MCP-PAG-001
if (isTimestampDerived(cursorGen)) → CRITICAL // SA-MCP-PAG-001
if (isBase64EncodedJson(cursorGen) && !hasHmacSignature(cursorGen)) → CRITICAL // SA-MCP-PAG-001
if (!hasSessionBinding(cursorGen)) → HIGH // SA-MCP-PAG-001

Predictable pagination cursors are IDOR vulnerabilities: Insecure Direct Object Reference via cursor token is identical in severity to IDOR via numeric URL parameters. The fact that cursors are “opaque” per spec means clients should not parse them — not that they are cryptographically opaque. Any cursor derivable from observable values (sequential IDs, timestamps, Base64-encoded state) is exploitable for cross-tenant enumeration.

CRITICAL
SA-MCP-PAG-001 — pagination cursors derived from sequential integers, timestamps, or row IDs: attackers predict valid cursors for other users’ sessions and enumerate their resources without authorization. Detection: nextCursor set to numeric string, timestamp, or unsigned Base64 JSON → CRITICAL.

SA-MCP-PAG-002: cursor forgery with crafted Base64 payloads accesses out-of-bounds resource pages

MCP servers that deserialize cursor content without validating its integrity allow an attacker to forge cursor values that instruct the server to return pages outside the authenticated user’s permission scope. Even a cursor that correctly contains the user’s own ID can be forged if the server does not verify the cursor’s authenticity before trusting its embedded state. Attackers craft cursors with different user IDs, negative offsets, or out-of-range page numbers to access resources they should not see.

// VULNERABLE: cursor decoded and trusted without integrity check
server.setRequestHandler('resources/list', async (req, context) => {
  let page = 0;
  let ownerId = context.userId;

  if (req.params?.cursor) {
    try {
      const decoded = JSON.parse(
        Buffer.from(req.params.cursor, 'base64').toString('utf8')
      );
      page = decoded.page;      // ← attacker sets page = -1 or 99999
      ownerId = decoded.userId; // ← attacker sets userId = 'admin' or 'usr_victim'
    } catch {
      // parse error — handled in SA-MCP-PAG-003
    }
  }

  // Server uses attacker-controlled ownerId to query resources
  const resources = await db.query(
    'SELECT * FROM resources WHERE owner_id = ? LIMIT 20 OFFSET ?',
    [ownerId, page * 20]  // ← wrong user's resources returned
  );
  return { resources: resources.map(toResource) };
});

// ATTACK:
const maliciousCursor = Buffer.from(
  JSON.stringify({ userId: 'usr_admin', page: 0 })
).toString('base64');
// Call resources/list with cursor = maliciousCursor → returns admin's resources

// ALSO ATTACK: negative offset to access pre-list entries (wraps to LIMIT - absolute(offset))
const negativeCursor = Buffer.from(JSON.stringify({ userId: context.userId, page: -5 })).toString('base64');

// SECURE: never trust embedded state in cursor — look up server-side pagination state instead
const paginationSessions = new Map(); // cursorToken → { sessionId, state }

server.setRequestHandler('resources/list', async (req, context) => {
  let state;
  if (req.params?.cursor) {
    const entry = paginationSessions.get(req.params.cursor);
    if (!entry) throw new McpError(ErrorCode.InvalidParams, 'Cursor expired or invalid');
    if (entry.sessionId !== context.sessionId) {
      throw new McpError(ErrorCode.InvalidParams, 'Cursor not valid for this session');
    }
    state = entry.state;
    paginationSessions.delete(req.params.cursor); // ← single-use cursor
  } else {
    state = { offset: 0 };
  }

  const resources = await db.query(
    'SELECT * FROM resources WHERE owner_id = ? ORDER BY id LIMIT 20 OFFSET ?',
    [context.userId, state.offset]  // context.userId always used — never from cursor
  );

  if (resources.length === 20) {
    const nextToken = crypto.randomBytes(32).toString('base64url');
    paginationSessions.set(nextToken, {
      sessionId: context.sessionId,
      state: { offset: state.offset + 20 },
      expiresAt: Date.now() + 5 * 60 * 1000  // 5-minute TTL
    });
    return { resources: resources.map(toResource), nextCursor: nextToken };
  }
  return { resources: resources.map(toResource) };
});

// DETECTION (SkillAudit):
// Does cursor parsing assign decoded fields to query parameters?
const cursorDecodeBlock = findCursorDecodeBlock();
if (assignsDecodedFieldsToQueryParams(cursorDecodeBlock) && !verifiesHmac(cursorDecodeBlock)) → CRITICAL // SA-MCP-PAG-002
// Does the list query use a field that could come from cursor rather than context?
if (queryUsesNonContextOwnerId(cursorDecodeBlock)) → CRITICAL // SA-MCP-PAG-002

Cursor forgery bypasses authorization that would block direct requests: A server may correctly reject resources/list?userId=admin but silently accept a forged cursor containing { "userId": "admin" } because cursor content is trusted as “already authenticated state.” The server’s own authorization logic is circumvented by the implicit trust placed in cursor contents.

CRITICAL
SA-MCP-PAG-002 — cursor content decoded and used in database queries without integrity verification: forged cursors with attacker-controlled userId, page offsets, or filter values bypass authorization and return other users’ resources. Detection: decoded cursor fields used in query WHERE clauses without HMAC validation → CRITICAL.

SA-MCP-PAG-003: malformed cursor input triggers parse errors that leak internal server structure

MCP servers that return verbose error responses when cursor parsing fails expose internal server structure: database table names, column names, ORM query formats, file system paths, or framework stack traces. An attacker systematically submits malformed cursors — truncated Base64, invalid JSON, type mismatches, SQL fragments — and reads error messages to reconstruct the server’s internal data model. This reconnaissance data directly aids cursor forgery (SA-MCP-PAG-002) and other injection attacks.

// VULNERABLE: parse errors returned verbatim to client
server.setRequestHandler('resources/list', async (req, context) => {
  if (req.params?.cursor) {
    let cursorData;
    try {
      cursorData = JSON.parse(
        Buffer.from(req.params.cursor, 'base64').toString('utf8')
      );
    } catch (e) {
      // Returns full parse error to client — reveals expected JSON structure
      throw new McpError(ErrorCode.InvalidParams,
        `Cursor parse error: ${e.message}. Expected: { userId: string, offset: number, tableName: string }`
      );
      // Attacker learns: cursor contains userId (string), offset (number), tableName (string)
    }

    // Later: DB error from malformed cursor fields also leaked
    try {
      await db.query(`SELECT * FROM ${cursorData.tableName} WHERE ...`);
    } catch (dbErr) {
      throw new McpError(ErrorCode.InternalError, `DB error: ${dbErr.message}`);
      // Attacker submits cursor with tableName="users'; DROP TABLE--"
      // Error message reveals real table names and query structure
    }
  }
});

// ALSO VULNERABLE: ORM framework stack trace in error
// SyntaxError: Unexpected token 'u' in JSON at position 0
//   at JSON.parse ()
//   at /app/src/handlers/resources.js:47:23
//   at MCPServer._handleRequest (/app/node_modules/@modelcontextprotocol/sdk/dist/server.js:234:18)
// Error message reveals: source file path, line number, ORM module path

// SECURE: return generic error messages; log details server-side only
server.setRequestHandler('resources/list', async (req, context) => {
  if (req.params?.cursor) {
    let cursorData;
    try {
      cursorData = JSON.parse(
        Buffer.from(req.params.cursor, 'base64').toString('utf8')
      );
      // Validate structure before use
      if (typeof cursorData?.offset !== 'number' || cursorData.offset < 0) {
        throw new Error('invalid offset');
      }
    } catch {
      // Generic error — no internal details exposed
      logger.warn('Invalid cursor received', {
        sessionId: context.sessionId.slice(0, 8),
        cursorLength: req.params.cursor.length
      });
      throw new McpError(ErrorCode.InvalidParams, 'Invalid pagination cursor');
    }
  }
});

// ALSO SECURE: reject any cursor that fails HMAC check before parsing
function decodeCursorSafe(token, sessionId) {
  const parts = token.split('.');
  if (parts.length !== 2) throw new McpError(ErrorCode.InvalidParams, 'Invalid cursor');
  const [payloadB64, sig] = parts;
  try {
    const expected = computeHmac(payloadB64);
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      throw new McpError(ErrorCode.InvalidParams, 'Invalid cursor');
    }
    return JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
  } catch {
    // Never propagate internal parse errors — always rethrow as generic McpError
    throw new McpError(ErrorCode.InvalidParams, 'Invalid cursor');
  }
}

// DETECTION (SkillAudit):
// Find cursor parse catch blocks: do they include e.message or stack in thrown error?
const parseCatchBlocks = findCursorParseCatchBlocks();
for (const block of parseCatchBlocks) {
  if (includesErrorMessageInResponse(block)) → HIGH // SA-MCP-PAG-003
  if (includesStackTraceInResponse(block)) → CRITICAL // SA-MCP-PAG-003
}
// Find dynamic SQL construction using cursor-derived fields
if (hasDynamicSqlFromCursorFields()) → CRITICAL // SA-MCP-PAG-003

Cursor parse errors are a low-effort reconnaissance vector: An attacker submits 10-20 malformed cursor variants (truncated, wrong types, SQL keywords, oversized) in a few seconds. Each error message that includes expected field names, types, or database error text reduces the effort required for cursor forgery from days of source-code analysis to minutes of black-box probing.

HIGH
SA-MCP-PAG-003 — cursor parse or validation errors returned with e.message or stack trace: malformed cursor submissions leak cursor schema (field names, types), database table/column names, and source file paths. Detection: catch blocks propagating e.message in cursor error responses → HIGH.

SA-MCP-PAG-004: concurrent cursor requests enumerate full resource list bypassing per-request limits

MCP list endpoints impose per-request page size limits (typically 20-100 items) to prevent single-call full enumeration. Pagination is intended to be sequential. But an attacker who obtains one cursor for page N can use it to derive or guess cursors for all other pages, then fire all page requests concurrently. Even without cursor predictability, if a server allows any authenticated user to start a new pagination session from offset 0, the attacker can systematically paginate through the entire list by making sequential requests faster than any rate limit would block, enumerating all resources across all tenants if access control is missing.

// VULNERABLE: no rate limit on resources/list; any authenticated user can paginate all resources
server.setRequestHandler('resources/list', async (req, context) => {
  const cursor = req.params?.cursor;
  const offset = cursor ? parseInt(Buffer.from(cursor, 'base64').toString(), 10) : 0;

  // No per-user resource filtering — returns ALL resources for any authenticated user
  const resources = await db.query(
    'SELECT * FROM resources ORDER BY id LIMIT 20 OFFSET ?',
    [offset]
  );
  return {
    resources: resources.map(toResource),
    nextCursor: resources.length === 20
      ? Buffer.from(String(offset + 20)).toString('base64')
      : undefined
  };
});

// ATTACK: cursor amplification — concurrent requests after getting first cursor
const firstPage = await client.request({ method: 'resources/list', params: {} });
const totalEstimate = 10000; // attacker knows or guesses total resource count
const pageSize = 20;
const pages = Math.ceil(totalEstimate / pageSize);

// Fire all pages concurrently — bypasses sequential rate limit
const allResources = await Promise.all(
  Array.from({ length: pages }, (_, i) => {
    const offset = i * pageSize;
    const cursor = Buffer.from(String(offset)).toString('base64');  // predictable cursor
    return client.request({ method: 'resources/list', params: { cursor } });
  })
);
// Complete resource enumeration in 1-2 seconds instead of rate-limited sequential calls

// SECURE: rate limit pagination sessions; bind cursor to origin session; enforce user scoping
const PAGINATION_RATE = new Map(); // userId → { count, windowStart }

server.setRequestHandler('resources/list', async (req, context) => {
  // Per-user rate limit: max 10 list requests per minute
  const now = Date.now();
  const rate = PAGINATION_RATE.get(context.userId) ?? { count: 0, windowStart: now };
  if (now - rate.windowStart > 60_000) {
    rate.count = 0; rate.windowStart = now;
  }
  rate.count++;
  PAGINATION_RATE.set(context.userId, rate);
  if (rate.count > 10) {
    throw new McpError(ErrorCode.InvalidRequest, 'Pagination rate limit exceeded');
  }

  // Always scope to authenticated user — never global enumeration
  const resources = await db.query(
    'SELECT * FROM resources WHERE owner_id = ? ORDER BY id LIMIT 20 OFFSET ?',
    [context.userId, getOffsetFromCursor(req.params?.cursor, context.sessionId)]
  );
  return { resources: resources.map(toResource), nextCursor: makeSignedCursor(resources, context) };
});

// ALSO SECURE: set nextCursor = undefined when resources.length < pageSize to signal end-of-list
// ALSO SECURE: consider totalCount field only when you can afford the COUNT query — otherwise omit

// DETECTION (SkillAudit):
// Does resources/list filter by context.userId / context.sessionId?
if (!filtersByAuthenticatedUser()) → CRITICAL // SA-MCP-PAG-004
// Is there a per-user or per-session rate limit on list requests?
if (!hasPaginationRateLimit()) → HIGH // SA-MCP-PAG-004
// Is the maximum page size enforced server-side (not trusting client-supplied limit)?
if (acceptsClientSuppliedLimit() && !capsLimit()) → HIGH // SA-MCP-PAG-004

Pagination amplification is a structural enumeration bypass, not just a DoS: The primary risk is not server load — it is that concurrent pagination retrieves the full resource catalogue in seconds, exposing all user data in a single attack session. Rate limits that apply per-request (not per-session or per-window) are trivially bypassed by concurrency. The fix requires a sliding-window rate limit at the user or session level, plus authorization filtering at the query level.

HIGH
SA-MCP-PAG-004 — no per-user rate limit on list endpoints combined with missing user-scoped filtering: concurrent cursor requests enumerate the full resource catalogue for all tenants. Detection: resources/list handler missing WHERE owner_id = context.userId and missing sliding-window rate limit → HIGH.

How SkillAudit detects SA-MCP-PAG patterns

PatternDetection methodSeverity
SA-MCP-PAG-001Static: nextCursor set to numeric string, timestamp, or unsigned Base64 JSON without HMAC; no session binding on cursor generationCRITICAL
SA-MCP-PAG-002Static: decoded cursor fields used in query WHERE clauses or query parameters without HMAC verificationCRITICAL
SA-MCP-PAG-003Static: cursor parse catch blocks propagating e.message or stack trace; dynamic SQL constructed from cursor-derived fieldsHIGH
SA-MCP-PAG-004Static: resources/list handler missing user-scoped WHERE clause; no sliding-window rate limit on list requestsHIGH

Run a SkillAudit scan on your MCP server’s pagination implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-PAG patterns and 200+ additional MCP security checks.