# 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.

Source: https://voxgig.com/howto/encode-and-sign-opaque-pagination-cursors

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://www.rfc-editor.org/rfc/rfc4648#section-5), 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](https://developer.mozilla.org/en-US/docs/Glossary/Base64) | 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](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options) | 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](https://www.postgresql.org/docs/current/sql-createtable.html) | 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](https://use-the-index-luke.com/no-offset) | 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.

```ts title="cursor.mjs"
/** 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.

```ts title="cursor.mjs"
  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](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) 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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test cursor.test.mjs
```

```text output
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

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Advertise next and previous pages with Link headers](/howto/link-headers-for-pagination-rfc-8288)

## 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.