A pagination cursor is not a forever pass
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:
GET /items?limit=50runs a real authorize + filter for the caller.- The response returns
next_cursorencoding offset, last id, or a signed blob of the original filter. 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+filterfrom ten minutes ago is still yesterday's grant. - Opaque cursors that only store
last_seen_idsilently 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
- Does
?cursor=hit the same authorize/filter path as the first page? - Can a revoked user still walk pages with an old cursor?
- Does changing object ownership mid-scroll remove rows from later pages?
- 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.
