A caller decodes a connection cursor, edits it and replays it — how should the server defend itself?
answer
- Ask what else the argument list contains
- Three separate failures, three separate fixes
- Minted for a question, not just by a server
- Order of composition decides the blast radius
- Narrow only, never widen
basics
~20 sTreat a decoded cursor as untrusted input. Bind values as parameters, carry a fingerprint of the ordering and filters so a replay under different arguments is rejected, and compose the authorization predicate into the same query the cursor seeks within.
solid answer
~50 sBase64 looks like ciphertext but is a public, reversible mapping, so a cursor is ordinary user input. Three things can go wrong. **Injection**, if decoded values are interpolated rather than bound — cursor-building code is often hand-rolled outside the usual query layer. **Replay under different arguments**: a cursor names a coordinate in the sort it was minted for, so replaying it under another ordering skips or repeats rows, or produces a scan no index supports. Carry a short hash of the normalized ordering and filters in the payload and reject a mismatch explicitly. **Forgery past an authorization boundary**, which is the serious one, and it is always a permission check that ran too late: if visibility is filtered after the keyset seek and page slice, pages come back short and a forged cursor seeks wherever it likes. The invariant to state: a cursor may narrow a result set, never widen one.
code
pseudocode · 14 lines// Compose in this order: authorize, filter, seek, limit.
base = store.select(where = visiblePredicateFor(viewer) // authorization joins the query
AND requestFilters)
payload = decodeCursor(after) // untrusted
require(payload.v == CURRENT_CURSOR_VERSION)
require(constantTimeEquals(payload.ord,
fingerprint(orderBy, requestFilters)))
rows = base.andWhere(tupleLessThan((filedAt, id),
(payload.filedAt, payload.id))) // parameters, not text
.orderBy(filedAt DESC, id DESC)
.limit(first + 1)
// A forged cursor can only name a position inside rows the viewer may already read.go deeper
Know that a cursor arriving from a caller is input like any other argument, and that base64 hides nothing. Recognising that a server must not simply trust what it decodes is what is expected at this level.
Explain the three distinct failures and their distinct fixes: bind values as parameters, carry a fingerprint of the ordering and filters, and never leave the seek unscoped. Be able to say why a replayed cursor under a different sort produces wrong rows rather than an error.
Show that you can order the composition. An interviewer wants the observation that filtering for permissions after the slice both shortens pages and leaves the seek unscoped, and the invariant that a cursor may narrow a result set but must never widen one.
Own it as a platform rule rather than a per-connection habit. Decide whether cursors are signed at all, who holds and rotates the key, how the authorization predicate is guaranteed to be composed into every paged query, and how that guarantee is tested rather than reviewed.
## The trust boundary people forget A connection cursor arrives as an argument, exactly like `first` or an ordering enum. It happens to be base64, and base64 *looks* like ciphertext, and that resemblance is where the vulnerability lives. Base64 is a public, reversible alphabet mapping: anyone can decode a cursor in a browser console, edit the JSON inside, re-encode it and send it back. **Opacity is a convention about client behaviour, not an enforcement mechanism.** So the rule is simple and absolute: a decoded cursor is untrusted input and gets validated like any other. Three distinct things can go wrong, and a strong answer separates them because they have different fixes. ## 1. Injection If the decoded values are interpolated into a query rather than bound as parameters, the cursor is a direct injection channel that most reviewers never look at, because it does not look like a user-supplied string. The fix is the ordinary one — parameterize — but it is worth naming, because cursor-building code is often hand-rolled outside whatever query layer the rest of the service uses. ## 2. Replay under different arguments A cursor is meaningful only inside the ordering and filter it was minted for. Its values name a coordinate in *that* sort. Replay a `filedAt`-descending cursor against a caption-ascending sort and the server builds a predicate over the wrong columns: the client gets rows skipped, rows repeated, or a scan that no index supports and that times out under load. The defence is to **bind the cursor to the query it came from**: put a short hash of the normalized ordering plus filter arguments inside the payload, recompute it on receipt, and reject a mismatch with an explicit request-level error naming the argument. Silently restarting from the head is the wrong response — it looks like success and corrupts an export. This binding is a widespread convention; nothing in the connection specification requires it. ## 3. Forging a position past an authorization boundary This is the one that matters, and the failure is almost always **a permission check that ran too late**. Picture the legal case-file graph. Outside counsel may see a matter's non-privileged filings. The connection resolver seeks with a keyset predicate into a filings index shared by every matter, slices 25 rows, and *then* runs a visibility check that drops the privileged ones before returning. Two things are now broken at once. Benignly: pages come back short — ask for 25, get 18 — because rows were removed after the slice. Dangerously: the seek itself was never scoped. A forged cursor naming a coordinate inside a different matter's filings lands the scan wherever the attacker chose, and everything the post-filter fails to catch — a filing on a matter the check does not model, an ordering field read from the row, an error message that leaks a caption — is exposed. The same shape appears when authorization is enforced only on the ancestor field: the `matter` resolver checks access to the matter, and the nested `filings` connection is assumed safe because you had to go through the parent. A forged cursor does not go through the parent's reasoning at all; it goes straight into the child's index. ## The load-bearing fix **The authorization predicate must be part of the same query the cursor seeks within.** Compose the base query as: the caller's visibility predicate, plus the request's filters, then apply the keyset predicate from the cursor, then the limit. In that order the cursor can only ever select *within* an already-authorized set. A forged cursor becomes uninteresting — the worst it can do is name a position the caller was already allowed to reach. Say the invariant explicitly, because it is the sentence an interviewer is waiting for: **a cursor may narrow a result set; it must never be able to widen one.** ## Signing, and what it does and does not buy An HMAC over the payload, keyed by a server-side secret, makes a cursor tamper-evident: an edited payload fails verification and is rejected before it reaches the query. That is genuinely useful defence in depth, and it also lets you catch clients that have started constructing cursors. The costs are real — key rotation invalidates outstanding cursors unless you verify against a small key set, and the tag adds bytes to a value minted once per edge. But be precise about what a signature proves. It proves *this server minted this payload*. It does not prove the payload was minted for *this* question, which is why the ordering fingerprint is a separate mechanism and not a redundant one. And it does not authorize anything: a signed cursor over an unscoped query is still an unscoped query. Signing is a supplement to the authorized base predicate, never a substitute for it. Encryption is the further step, and it answers a different question again — not "was this altered" but "may the caller read what is inside". Reach for it only when the payload genuinely carries something sensitive, and prefer not putting that thing in a cursor at all.
- If cursors are signed with a server-side key, is the ordering fingerprint still needed?Yes, because the two answer different questions. A signature proves this server minted the payload; it says nothing about which query it was minted for. A perfectly genuine cursor from a filedAt-descending page is still meaningless replayed under a caption-ascending sort, and will skip or repeat rows. The signature answers "is it forged"; the fingerprint answers "is it for this question".
- What should the server return when a cursor's fingerprint does not match the current arguments?An explicit request-level failure naming the offending argument, so the client can correct the call. The tempting alternative — quietly restarting from the head of the list — is worse than an error: it looks like success, and an export or sync built on it silently loses or repeats everything between the two positions. Fail loudly; paging bugs that return plausible data are the expensive kind.
- Why isn't keeping the cursor format undocumented a defence in itself?Because it is obscurity, not security: the payload is one decode away and its structure is guessable from the connection's ordering arguments. What secrecy genuinely buys is freedom to change the format without breaking clients, which is a versioning benefit. Anything that must actually resist an attacker needs a keyed signature, and anything that must stay unreadable needs encryption or simply should not be in the cursor.
A cursor is a page number, not a key to the building. If the reader could only ever get into the room they were already admitted to, it does not matter which page number they write down.
saying these in an interview costs you the question
- Base64 hides the cursor's contents from callers
- A signed cursor removes the need to authorize rows
- Cursors are safe because clients cannot construct one
- Interpolating decoded cursor values into a query is fine
- Filtering unauthorized rows out after the page slice is fine
- A cursor from a different sort just returns odd ordering