Skip to main content

Command Palette

Search for a command to run...

A pagination cursor is not a forever pass

Updated
•2 min read•View as Markdown
A
Practical lessons on auth, authorization, and access control.

List endpoints often authorize once on page one, then trust the cursor for every next page. That is how a revoked membership keeps paging through results you meant to cut off.

The bug pattern

A common flow:

  1. GET /items?limit=50 runs a real authorize + filter for the caller.
  2. The response returns next_cursor encoding offset, last id, or a signed blob of the original filter.
  3. GET /items?cursor=... skips the policy check and just resumes the scan.

Between page one and page three, the caller can lose access: role revoked, tenant membership ended, object ownership transferred, or a parent resource soft-deleted. The cursor still works because nothing re-evaluated actor + action + resource set.

Why "the cursor is signed" is not enough

Signing the cursor stops tampering. It does not freeze a permission decision in amber.

  • A signed subject_id + filter from ten minutes ago is still yesterday's grant.
  • Opaque cursors that only store last_seen_id silently inherit whatever the DB returns next — including rows the caller should no longer see.
  • Export / infinite-scroll clients hold cursors for a long time; revocation windows matter more there than on a single HTML page.

Integrity of the cursor ≠ freshness of authorization.

What to enforce

On every page request, including cursor continuations:

  • Re-derive the allowed set. Run the same authz filter (or PDP query) you used for page one, then apply the cursor inside that set.
  • Bind identity tightly. If the cursor carries a subject or tenant, it must match the current session; mismatch → 401/403, not a helpful resume.
  • Fail closed on stale grants. Prefer short cursor TTL plus re-check over "resume forever for UX."
  • Watch secondary paths. GraphQL connections, search "load more," and async exporters that replay cursors need the same rule — not only the primary REST list handler.

Tiny checklist

  1. Does ?cursor= hit the same authorize/filter path as the first page?
  2. Can a revoked user still walk pages with an old cursor?
  3. Does changing object ownership mid-scroll remove rows from later pages?
  4. Are export jobs using the same rule, or a privileged "scan everything" shortcut?

Pagination is a read strategy. Authorization has to stay live on every page, not only the first.