- Published on
Make API cursors opaque and bind them to the query
- Authors

- Name
- Mehdi Akiki
Article · Derived state
The first page uses a filter for open orders. The client changes the filter to closed orders but keeps the old cursor.
What does the next page mean now?
If the server treats a cursor as only the last row ID, it may accept that combination and return an inconsistent slice. A cursor should carry enough context to reject a query it was not created for.
Bind the position to its meaning
A cursor position depends on ordering, filters, and scope. For a tenant-scoped list ordered by creation time and ID, a Rust model might look like this:
struct CursorV1 {
version: u8,
tenant_id: String,
query_identity: String,
after_created_at_micros: i64,
after_id: String,
expires_at: u64,
}
This is the internal model used by the linked signing fixture. The codec below that type supplies bounded decoding and integrity verification. A deployed token also needs a key-management and rotation policy.
Build query_identity from the filter, sort, and other options that change the result. Use one stable encoding for those inputs. Hashing arbitrary JSON can give different identities for the same query if key order changes.
The cursor's tenant must match the server-authenticated request scope. Never adopt a tenant merely because it appears inside a validly signed token.
The pagination article explains position-based access. Google's pagination design guidance also discusses opaque tokens and consistent request parameters across pages.
Opaque does not mean secret
Base64 encoding makes a token inconvenient to read, not confidential or tamper-proof. Signing protects integrity when implemented correctly, but the client can still see signed plaintext.
Avoid putting private data or internal schema details into a public token without a reason. Use an authenticated encryption scheme or server-side token state if confidentiality is part of the requirement. Do not invent a home-made crypto format for an article example.
Version the envelope so the server can reject or migrate old formats deliberately. Set a reasonable expiry if long-lived cursors would retain unsupported assumptions or expensive server state.
Preserve a stable ordering
Binding a token to the query does not fix an unstable sort. Include a deterministic tie-breaker and define how concurrent inserts, updates, and deletions affect later pages.
If the API promises a snapshot, the implementation needs a snapshot mechanism or equivalent retained state. A stateless cursor over live rows usually provides a different contract. Say what the reader can expect rather than implying a frozen list.
Keep authorization on every page request. Access can change between pages, and possession of yesterday's cursor is not a new grant.
Test incompatible reuse
Check the next page under the original filter, then change the filter, tenant, sort direction, and token version separately. Each unsupported combination should produce the documented invalid-cursor result.
Add malformed and oversized tokens before decoding expensive structures. Test equal timestamps so the secondary key actually matters. Include deleted boundary rows if the contract permits live pagination.
Clients should treat the token as an opaque value to send back. The server should treat it as a bounded, validated request input. That leaves room to change storage later without making a database column layout part of the public API forever.
A signed cursor with a real next-page query
The reproduction serializes a versioned cursor and authenticates its bytes with HMAC-SHA256. The token uses URL-safe Base64 for transport. Verification bounds the encoded token and decoded body, checks the tag with the HMAC crate's verification method, then parses the fields. Authentication is not implemented with an ordinary string-equality comparison.
The verified tenant must match the server's request scope. The query identity must match the expected filter and ordering. Version and expiry must also be supported. The test changes each of these inputs independently, and includes a wrong key, malformed token, oversized token, and modified content. Each incompatible reuse is rejected.
The database part uses PostgreSQL rows with tied creation times and an ID tie-breaker. After the first boundary row is deleted, the next query still returns IDs 02 and 03 using the tuple position. A tenant-B row does not enter tenant A's page. The query asks for a bounded number of rows rather than loading the whole table and slicing it afterward.
The complete codec and query test uses a fixture signing key. A deployed service needs secret management, rotation, and a policy for old tokens. The example's query identity is an explicit server-controlled string; a real filter object needs a stable canonical identity too.
The signed body remains readable. It provides integrity, not encryption. The live query also remains live: rows can change between requests. Deleting a boundary row works here because the token carries its position, not because it retains a database snapshot.
Choose the promise first. If a report requires one consistent historical snapshot, use storage or retained state that can provide it. If the interface is a live ordered list, document that behaviour and test the changes it allows. A well-formed cursor should make its actual promise enforceable rather than conceal a stronger one.