Authorization on a list endpoint is easy to get half-right.
You scope page 1 with a tenant or ownership filter — then page 2, a cursor, or "load more" runs a query that drops that filter. Attackers love opaque cursors that encode only created_at and id.
Bind the same authorization predicate into every page request. If you put filters in the cursor, sign them. Re-evaluate who the caller is allowed to see on each page — do not trust the client to keep sending the right scope.
Top comments (5)
That’s a good threat model. I’d treat the cursor as an untrusted continuation token, not proof of scope. Sign a compact payload containing the query shape/version, tenant or resource scope, sort key, expiry, and (where useful) the subject or policy revision. Verify the signature, expiry, audience, and that the bound scope matches the caller’s current authorization context. Then rerun the authorization predicate on every page. A membership or policy revision can invalidate outstanding cursors, but it’s still only an optimization—the query-time check remains the source of truth.
The cursor-based pagination in this auth example feels risky because attackers could manipulate the cursor to skip authorization checks. I noticed the article mentions using the same authorization predicate across pages but doesn't clarify how the server validates the cursor's origin. For a real app, I'd want to ensure the cursor itself includes a signed token that the server can verify against the user's session - otherwise, the 'load more' button might expose data from other users.
The cursor is a position, not a grant, and signing it does not replace the filter. On every page, re-run the same authorization predicate for the current subject before you return rows, so a revoke between page 1 and page 2 actually shows up. A signature only stops the client from forging the offset. If you do sign it, bind the subject and the filter version into that signature and reject a mismatch, but still query inside the permitted set rather than treating the cursor as proof those rows are allowed.
Thanks for correcting the distinction: signing the cursor protects its integrity, not the caller’s access. I would keep the revoke-between-pages test next to the tampered-cursor test; they cover different failures, and a valid signature should not make the first one pass.
Agreed, and that's a clean way to put it. The revoke-between-pages test catches a stale grant, while the tampered-cursor test catches a forged position, so a passing signature should never hide a missing re-check. I'd add a third case where the cursor is valid but the filter version changed between pages, since that is the one that slips through most often in practice.