Your list endpoint takes after_id, and a customer noticed. They now pass ids they were never given
to read rows they were not shown, and one of them has hardcoded the parameter into a scheduled job.
Changing the sort order would break that job, so the position you chose in an afternoon is now part
of your public contract.
What you get
You will end up with a cursor that callers can pass back and cannot construct or edit, and a decoder that refuses a tampered one. This is for you if your paging position is a raw column value.
Short answer
Serialize the position as JSON, encode it base64url so it is safe in a query string, and append an HMAC over that encoding. Verify the HMAC with a constant-time comparison before parsing, and refuse anything that fails. The caller carries a value they cannot read or edit, and you keep the freedom to change what is inside it.
You will need
Node 22 or later, a keyset-paginated endpoint, and a secret held only by your servers. The encoding is base64url from RFC 4648, which differs from base64 in two characters and needs no percent-encoding in a query string.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A plain base64 cursor | Internal endpoints where callers have no reason to forge one | Anyone can decode and edit it, so it is obfuscation rather than a control | The endpoint is public, or the position names a tenant |
| A signed base64url cursor | Public APIs that page over data with a tenant or a filter in the position | A secret to hold and rotate, and a few milliseconds of hashing per request | The position is a single public identifier with nothing to protect |
| An opaque id in a table | Positions that must expire, or be revoked, or survive a schema change | A row per active cursor, plus the job that cleans them up | You page many collections and do not want the storage |
| Raw column values | A first version, or an endpoint with one internal caller | Every caller learns your columns, and the sort becomes part of the contract | Anyone outside your team calls it |
Signing and storing solve different halves. A signature proves the cursor came from you, and stores nothing. A stored id can be revoked and expired, and costs a table. Most public list endpoints want the signature: expiry can go inside the payload, and revocation rarely matters for a position in a list.
Sign the encoding, not the object
The signature covers the exact bytes the caller returns, which is what makes verification cheap and unambiguous.
/** Encodes the position, plus the query it belongs to, and signs the pair. */
export function encodeCursor(position, secret) {
const payload = b64url(JSON.stringify(position))
return `${payload}.${b64url(sign(payload, secret))}`
}
Signing the encoded string rather than the object removes a whole category of bug. Two JSON serializations of the same object can differ in key order or spacing, and a signature over the object would then verify or fail depending on which library produced it. The caller hands back a string, and that string is what gets hashed.
Put everything the position depends on inside it. The sort, the filter, and the tenant all belong
there, so a cursor from one query cannot be replayed against another. A cursor holding only
after_id verifies perfectly when a caller pastes it into a different filter, and returns rows from
a query they never ran.
Compare in constant time
Verification is where a careless line leaks the secret one byte at a time.
const expected = sign(payload, secret)
const given = Buffer.from(mac, 'base64url')
// Length has to match before timingSafeEqual, which throws on a mismatch,
// and a comparison that returns early on length is not the leak that matters.
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
throw new Error('cursor signature does not verify')
}
timingSafeEqual compares every byte whatever it finds, so the time it takes says nothing about how far along the first difference was. A plain equality check returns as soon as two bytes differ, which is enough signal to recover a valid signature given enough attempts.
Verify before parsing, always. Parsing first and verifying second means untrusted JSON reaches your parser, and any per-field handling that runs during parsing runs on input an attacker chose.
The payload deserves the same care as the signature. Bound its size before decoding, because a megabyte of base64 in a query string costs you a parse before it costs the caller anything. Treat every field inside as untrusted after decoding, even though the signature proves you issued it. Your own code wrote those values, and a bug in the encoder is not caught by verifying that the bug was signed correctly.
Check it worked
Encode one cursor, then try the three things a caller might do to it.
node demo.mjs
cursor: eyJhZnRlcl9pZCI6NDIsInNvcnQiOiJjcmVhdGVkX2F0IiwidGVuYW50IjoiYWNtZSJ9.kFc7jaNVSq6bvz445jkKVE5hptliPkpSw3XhyUyH6GY
decoded: {"after_id":42,"sort":"created_at","tenant":"acme"}
edited payload rejected: cursor signature does not verify
truncated rejected: cursor signature does not verify
no signature rejected: cursor is malformed
The edited payload is the one that matters: it carries a different tenant and a lower id, and it is
refused before any query runs. The cursor is also unchanged by encodeURIComponent, which is the
property base64url buys.
node --test cursor.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 113.435482
When it goes wrong
The cursor verifies and returns the wrong rows. A signature proves the cursor came from you, and it says nothing about who is holding it now. A cursor issued to one customer and pasted by another still verifies, so the authorization check on every page has to run against the caller rather than against the cursor.
The second failure arrives with the first key rotation. Every cursor in flight was signed with the old secret, and rotating in one step invalidates all of them, which shows up as errors from clients in the middle of a walk. Accept both keys during a window: verify against the current secret, fall back to the previous one, and always sign with the current.
When not to do this
Do not put anything secret inside a cursor. The payload is encoded and signed, not encrypted, so anyone can read it. Internal row identifiers are usually fine, and a customer’s email address in there is a leak with a signature attached.
Do not sign cursors with the key you use for anything else. A cursor secret is used on every list request and is the most exposed key you hold, so give it its own value and its own rotation.
Do not let a cursor live forever. Put an issued-at value in the payload and refuse cursors older than a day or two, because a position from six months ago points into data that has since been deleted.
Do not return a 500 when verification fails. A bad cursor is a bad request, and a 400 with a clear message tells a client to start the walk again rather than to retry the same broken value.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed. The cursor value is reproducible because the demo signs with a fixed secret.