Secure backward pagination with scoped opaque cursors
Design gap-free backward pagination with chronological page output and hostile cursors bound to the exact authorized query and snapshot.
Secure backward pagination with scoped opaque cursors
Trigger
Use this guidance when an API fetches older records in descending order with one lookahead row, returns each page chronologically, and accepts a user-controlled cursor that may remain after the caller changes threads or filters.
Failure prevented
This prevents gaps from discarding or resuming at the wrong row, duplicates from inconsistent ordering, cursor reuse across callers or query scopes, authorization bypass, internal identifier disclosure, snapshot drift, and retry races caused by mutable cursor state.
Practical steps
- Define one immutable total order. If sequence values can tie, add a unique tie-breaker and use the same tuple with lexicographic comparisons for ordering, snapshot boundaries, and resume predicates. Keep internal tie-breakers inside protected state.
- On the first request, authenticate the caller, authorize the requested thread, derive the effective authorization scope and filters after defaults and policy restrictions, serialize filters canonically, and establish a snapshot.
- Fetch page size plus one rows in descending order within that snapshot. When overflow exists, remove the final descending row, which is the oldest lookahead, before reversing the retained page. Build the exclusive next position from the oldest retained row, never from the discarded lookahead. Emit a cursor only when overflow proves more rows exist.
- Expose a signed cryptographically random handle whose server-side record binds endpoint purpose, format version, caller and effective authorization scope, internal thread, canonical filter digest, backward direction, original snapshot, ordering definition, exclusive position, and expiration. Authenticated encryption is a stateless alternative. A readable signed payload has integrity but does not conceal identifiers.
- On continuation, bound parsing, verify authenticity and expiry before trusting cursor state, independently reauthorize the current request, and recompute every scope binding from that request. Reject any mismatch before querying. Never let cursor claims select the thread or filters, and never treat a valid cursor as authorization.
- Return one generic restart-pagination error for malformed, tampered, expired, missing, unauthorized, or wrong-context cursors. Do not reveal the failed binding or log raw cursor values.
- Query with the original snapshot and an exclusive predicate below the protected resume tuple. Mint a fresh immutable cursor for each next page by copying the protected context and changing only the position. Do not mutate a shared handle, so retries and concurrent requests remain safe.
- For sequences eight through one and page size three, fetch eight, seven, six, five; discard five; return six, seven, eight; and resume strictly below six. The discarded five remains eligible for the next page.
Limits
A high-water order boundary excludes later monotonic inserts only when records are immutable and never backfilled below the boundary. Edits, deletions, backfills, or changes in filter membership require revisioned point-in-time reads or a materialized snapshot for strict repeatability. Cursor expiration must not exceed snapshot retention. Bind page size when changing it would alter the pagination contract.
Support
This guidance is supported by a worked sequence trace, security analysis, and independent review conducted as reasoned analysis. No implementation, database, or executable tests were run.