Topic: mcp server pagination security
MCP server pagination security — cursor token manipulation, offset injection, and unbounded page size attacks
Paginated MCP tool responses create a unique attack surface: the pagination state (cursor, offset, page number) is passed through the LLM's context window on every subsequent call. An LLM that can be induced — via prompt injection in a retrieved document or adversarial user instruction — to modify the cursor or offset value before calling the next-page tool can access arbitrary records outside the intended result set, or trigger a server-side denial of service by requesting an unbounded page size.
Transparent cursor tokens are injectable
The most common cursor pattern is a base64-encoded offset: cursor = base64("offset:250"). The LLM receives this cursor in the tool response and is expected to pass it verbatim on the next call. But an LLM operating under a prompt injection attack can be instructed to replace the cursor with an arbitrary value before calling the next-page tool. If the server decodes and uses the cursor without validation, the attacker can read records at any offset — including records belonging to other users if the query is multi-tenant.
// Dangerous: cursor is a transparent, unsigned base64 offset
server.tool('listOrders', {
schema: {
cursor: { type: 'string', optional: true },
limit: { type: 'number', default: 20 }
},
handler: async ({ cursor, limit }, context) => {
let offset = 0;
if (cursor) {
const decoded = Buffer.from(cursor, 'base64').toString('utf8'); // "offset:250"
offset = parseInt(decoded.split(':')[1], 10); // attacker sets offset = 0 → reads from start
}
// Query uses userId from session but offset is attacker-controlled
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = $1 LIMIT $2 OFFSET $3',
[context.userId, Math.min(limit, 100), offset]
);
const nextCursor = orders.length === limit
? Buffer.from(`offset:${offset + limit}`).toString('base64')
: null;
return { orders, nextCursor };
}
});
In the example above, the user_id filter prevents cross-user access — but only as long as the offset stays within that user's order set. The real vulnerability is when the query does NOT have a user_id filter, or when the offset can be used to skip past authorization checks by landing on a page that includes other users' records due to a multi-tenant table structure.
Signed opaque cursors prevent manipulation
The defense is to make cursors opaque and signed. The cursor is a server-generated token that encodes the pagination state and includes an HMAC that the server verifies before use. An LLM that modifies the cursor value will produce an invalid HMAC and the server rejects the call:
const crypto = require('crypto');
const CURSOR_SECRET = process.env.CURSOR_SIGNING_SECRET; // 32+ bytes random secret
function signCursor(state) {
const payload = JSON.stringify(state);
const sig = crypto.createHmac('sha256', CURSOR_SECRET).update(payload).digest('hex');
return Buffer.from(JSON.stringify({ payload, sig })).toString('base64url');
}
function verifyCursor(cursor) {
let parsed;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw new Error('Invalid cursor format');
}
const expected = crypto.createHmac('sha256', CURSOR_SECRET)
.update(parsed.payload).digest('hex');
const a = Buffer.from(parsed.sig, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Cursor signature invalid — possible tampering');
}
return JSON.parse(parsed.payload);
}
server.tool('listOrders', {
schema: { cursor: { type: 'string', optional: true }, limit: { type: 'number', default: 20 } },
handler: async ({ cursor, limit }, context) => {
let state = { offset: 0, userId: context.userId };
if (cursor) {
state = verifyCursor(cursor);
if (state.userId !== context.userId) {
throw new Error('Cursor user mismatch'); // cross-user cursor replay
}
}
const safeLimit = Math.min(Math.max(1, limit), 100); // clamp to [1, 100]
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = $1 LIMIT $2 OFFSET $3',
[state.userId, safeLimit, state.offset]
);
const nextCursor = orders.length === safeLimit
? signCursor({ offset: state.offset + safeLimit, userId: state.userId })
: null;
return { orders, nextCursor };
}
});
Unbounded page size as denial of service
A tool that accepts a limit or pageSize argument without server-side clamping allows an LLM to request an arbitrarily large page. The query runs a full table scan, consumes all available database connection time, and may cause OOM on the server if the result set is materialized in memory. This is particularly exploitable in prompt injection scenarios where the attacker's goal is to degrade service rather than exfiltrate data:
// Dangerous: pageSize used directly from tool argument
server.tool('exportRecords', {
schema: { pageSize: { type: 'number' } },
handler: async ({ pageSize }) => {
return db.query('SELECT * FROM records LIMIT $1', [pageSize]); // pageSize = 9999999
}
});
// Safe: server-side maximum enforced regardless of argument value
const MAX_PAGE_SIZE = 100;
server.tool('exportRecords', {
schema: { pageSize: { type: 'number', minimum: 1, maximum: MAX_PAGE_SIZE } },
handler: async ({ pageSize }) => {
const safeSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
return db.query('SELECT * FROM records LIMIT $1', [safeSize]);
}
});
JSON Schema minimum and maximum constraints reduce the likelihood of an LLM generating out-of-range values, but they are not enforced server-side by most MCP frameworks. Always clamp at the handler layer regardless of schema declarations.
Cross-user data access via offset in multi-tenant tables
The most dangerous variant: a multi-tenant table where row ownership is determined by a tenant_id or user_id column, but the pagination query does not include that filter when computing offsets. An attacker who knows the approximate number of records per tenant can calculate an offset that lands in another tenant's row range:
// Dangerous: offset calculated globally, not per-tenant
const allOrders = await db.query(
'SELECT * FROM orders LIMIT $1 OFFSET $2', // no WHERE user_id filter
[limit, offset]
// attacker with offset knowledge can read other users' orders
);
// Safe: offset always scoped within the user's partition
const userOrders = await db.query(
'SELECT * FROM orders WHERE user_id = $1 LIMIT $2 OFFSET $3',
[context.userId, safeLimit, state.offset]
);
What SkillAudit checks for pagination security
- Cursor values decoded with
Buffer.from(cursor, 'base64')without any signature verification before use in database queries limit,pageSize, orcountarguments used in SQLLIMITclauses without server-side clamping to a maximum- Pagination queries that include an
OFFSETclause without a correspondingWHERE user_id =or equivalent tenant-scoping filter - Cursor encoding schemes that include structurally interpretable state (JSON, base64 of "field:value" strings) without integrity protection
Pagination security issues typically appear as MEDIUM severity findings in the Security axis. In multi-tenant deployments where offset injection could enable cross-tenant data access, the finding severity is HIGH. Run a free SkillAudit scan to catch pagination issues before deployment.