Secure backward pagination with scoped opaque cursors
Design backward cursor pagination that returns chronological pages without gaps and prevents cursors from crossing callers, threads, filters, directions, or snapshots.
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 client-held cursor that may survive a filter or scope change.
Failure prevented
This prevents four related failures:
- Removing the wrong end of the fetched rows, which changes page contents.
- Building a strict cursor from the discarded lookahead, which skips that record.
- Reusing or altering a cursor across a caller, thread, filter set, direction, or snapshot.
- Exposing internal identifiers in a readable cursor or claiming snapshot stability that the storage model cannot provide.
Practical steps
- Define one total, stable ordering. If the main sequence is not unique, add a tie-breaker and use the identical tuple and direction in both ordering and cursor predicates.
- On the first request, authenticate the caller, authorize the requested scope, derive the effective filters after defaults and authorization restrictions, and serialize them canonically. Establish a snapshot before querying.
- Fetch page size plus one matching rows in descending order within that snapshot. If the extra row exists, it is the oldest fetched row. Remove it while the list is descending, then reverse the retained rows for chronological output.
- Use the oldest retained row as the exclusive position for the next request. For a descending fetch of eight, seven, six, five with page size three, discard five, return six, seven, eight, and set the next position to six. The next query retrieves positions strictly before six, so five remains eligible.
- Emit a next cursor only when an overflow row proved that more results exist.
- Treat every cursor as hostile input. Protect a cursor format version, endpoint purpose, caller or tenant binding, scope binding, canonical filter binding, direction, snapshot, ordering version, oldest retained position, and expiration.
- A signature prevents modification but does not conceal a readable payload. Use authenticated encryption, or expose only a cryptographically random authenticated handle to server-side state. Do not place raw internal identifiers in readable fields.
- On every later request, authenticate and reauthorize the current scope. Bound the token size, verify authenticity before trusting protected claims, recompute the current bindings, and reject any mismatch before querying. A valid cursor is not authorization.
- Return one generic restart-pagination error for malformed, tampered, expired, or wrong-context cursors. Do not disclose which binding failed or log raw cursor contents.
- Query under the cursor's original snapshot and exclusive ordering position. Each next cursor copies the protected context and changes only the position. Clamp page size server-side and bind it when the API forbids page-size changes within a chain.
Snapshot limits
A high-water ordering value is sufficient only when records are immutable, strictly monotonic, and never backfilled below the watermark. If records can be edited, deleted, or move across filter membership, use revisioned point-in-time reads or a materialized snapshot. Cursor expiration must not exceed snapshot retention.
Support level
This guidance is supported by a worked reasoning trace and security analysis. No implementation, database, or executable pagination tests were run.