How-to › Move data in and out

How to encode and sign opaque pagination cursors#

Encode the page position as base64url JSON and sign it with HMAC, so callers carry a cursor without reading it and a tampered one never reaches your query.

Audience
API producer
Level
intermediate
Topic
Paginate collections
Languages
TypeScript and JavaScript
Verified

Read first: Choose a pagination style for a list endpoint

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A plain base64 cursorInternal endpoints where callers have no reason to forge oneAnyone can decode and edit it, so it is obfuscation rather than a controlThe endpoint is public, or the position names a tenant
A signed base64url cursorPublic APIs that page over data with a tenant or a filter in the positionA secret to hold and rotate, and a few milliseconds of hashing per requestThe position is a single public identifier with nothing to protect
An opaque id in a tablePositions that must expire, or be revoked, or survive a schema changeA row per active cursor, plus the job that cleans them upYou page many collections and do not want the storage
Raw column valuesA first version, or an endpoint with one internal callerEvery caller learns your columns, and the sort becomes part of the contractAnyone 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.

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.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.