Encoding Opaque Cursor Tokens with Base64URL

This page sits under Offset vs Cursor Pagination, part of the Query Patterns & Data Shaping Strategies reference. It goes one level below Implementing Cursor-Based Pagination with PostgreSQL: once you have a keyset query, the cursor you hand back to clients has to survive URLs, resist tampering, and stay stable while your schema evolves. That is an encoding and integrity problem, and this is how to solve it.

The Symptom That Leads Here

Your keyset query works, so you serialise { "created_at": "...", "id": "..." } to JSON and put it straight in ?cursor=. Then one of these happens:

Symptom Root cause
Cursors break intermittently in the wild Raw JSON or standard base64 contains +, /, =, {, " — reserved in URLs
A curious client edits the cursor and pulls rows they should not see The cursor is unsigned; the server trusts client-supplied keyset values
A schema migration silently returns wrong pages for in-flight cursors No version field to distinguish old from new payload layouts
Support asks “what page was this user on?” and no one can tell The format is undocumented and unversioned, so it cannot be decoded safely

The fix is a single discipline: encode a versioned, signed payload with base64url, and treat the token as opaque on the wire. Clients must never parse it; the server owns the format completely.

Why the Token Must Be Opaque

An opaque token is a string the client copies back verbatim and never interprets. This is not cosmetic. The moment a client parses created_at out of your cursor, that internal field becomes part of your public contract — you can no longer change the sort key, add a tiebreaker, or switch columns without breaking every consumer that learned the format. Opacity plus a version field is what buys you freedom to change the keyset later. Base64url is the encoding that makes the opaque blob URL-safe; HMAC is what makes it trustworthy.

The Contract Snippet

Declare the cursor as an opaque string. The description tells humans it is not parseable; the schema keeps it a plain string so no generated client tries to destructure it.

# openapi/components/schemas/PageInfo.yaml (OpenAPI 3.1.0)
PageInfo:
  type: object
  required: [has_next_page]
  properties:
    next_cursor:
      type: string
      nullable: true
      description: >
        Opaque base64url token. Treat as an integer-like handle: pass it back
        unchanged as ?cursor=. Do not decode, parse, or construct it. Null when
        has_next_page is false. Tokens are signed; a tampered token returns 400.
      example: "eyJ2IjoxLCJjIjoiMjAyNi0wNy0wMSIsImkiOiI5OSJ9.Yk9m3Q"
    has_next_page:
      type: boolean
      description: True when at least one more page follows.

How the Token Is Built

Signed base64url cursor token construction A keyset payload with version, created_at and id is signed with HMAC-SHA256 using a server secret, then the payload and signature are joined and base64url-encoded into an opaque cursor string. Building an opaque cursor Keyset payload v: 1 created_at, id HMAC-SHA256 over payload bytes with server secret secret key (server-only) payload . signature joined with a dot separator base64url token → ?cursor=

Step-by-Step: Encode and Verify

Step 1 — Define a compact, versioned payload

Keep keys short (v, c, i) so the encoded token stays small — cursors travel in URLs and logs. The v field is non-negotiable: it is your escape hatch for schema changes.

Step 2 — Sign, join, and base64url-encode

Node (built-in crypto):

import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CURSOR_SECRET!; // 32+ random bytes, rotated via KMS
const CURSOR_VERSION = 1;

type Keyset = { created_at: string; id: string };

function b64url(buf: Buffer): string {
  return buf.toString('base64url'); // Node strips padding automatically
}

export function encodeCursor(row: Keyset): string {
  const payload = Buffer.from(JSON.stringify({ v: CURSOR_VERSION, c: row.created_at, i: row.id }));
  const sig = createHmac('sha256', SECRET).update(payload).digest();
  // Truncated signature (16 bytes) keeps tokens short while staying unforgeable in practice
  return `${b64url(payload)}.${b64url(sig.subarray(0, 16))}`;
}

export function decodeCursor(token: string): Keyset {
  const [p, s] = token.split('.');
  if (!p || !s) throw new CursorError('malformed cursor');
  const payload = Buffer.from(p, 'base64url');
  const expected = createHmac('sha256', SECRET).update(payload).digest().subarray(0, 16);
  const given = Buffer.from(s, 'base64url');
  if (given.length !== expected.length || !timingSafeEqual(given, expected))
    throw new CursorError('cursor signature mismatch');
  const data = JSON.parse(payload.toString('utf8'));
  if (data.v !== CURSOR_VERSION) throw new CursorError(`unsupported cursor version ${data.v}`);
  return { created_at: data.c, id: data.i };
}

class CursorError extends Error {}

Python (built-in hmac):

import base64, hashlib, hmac, json, os

SECRET = os.environ["CURSOR_SECRET"].encode()  # 32+ random bytes
CURSOR_VERSION = 1


class CursorError(ValueError):
    pass


def _b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def encode_cursor(created_at: str, id_: str) -> str:
    payload = json.dumps({"v": CURSOR_VERSION, "c": created_at, "i": id_},
                         separators=(",", ":")).encode()
    sig = hmac.new(SECRET, payload, hashlib.sha256).digest()[:16]
    return f"{_b64url(payload)}.{_b64url(sig)}"


def decode_cursor(token: str) -> dict:
    try:
        p, s = token.split(".")
    except ValueError:
        raise CursorError("malformed cursor")
    payload = _b64url_decode(p)
    expected = hmac.new(SECRET, payload, hashlib.sha256).digest()[:16]
    if not hmac.compare_digest(_b64url_decode(s), expected):
        raise CursorError("cursor signature mismatch")
    data = json.loads(payload)
    if data.get("v") != CURSOR_VERSION:
        raise CursorError(f"unsupported cursor version {data.get('v')}")
    return {"created_at": data["c"], "id": data["i"]}

Step 3 — Reject tampered and expired tokens

timingSafeEqual / hmac.compare_digest are mandatory: a naive === on the signature leaks timing information an attacker can use to forge a valid MAC byte by byte. On any failure — bad signature, unknown version, malformed structure — return 400 with an RFC 9457 problem+json body. If you want cursors to expire, add an iat (issued-at) field to the payload and reject anything older than your window; expiry belongs in the signed payload, never in a separate parameter a client could edit.

RFC and Standard Alignment

Standard Relevance
RFC 4648 §5 The base64url alphabet (- for +, _ for /) and optional padding omission
RFC 3986 §3.4 Query component rules that make standard base64 unsafe and base64url safe
FIPS 198-1 / RFC 2104 HMAC construction used to sign the payload
RFC 9457 Problem Details body returned when a cursor fails verification

Caching and Safety Implications

A signed cursor makes a paginated GET genuinely idempotent: the same token always names the same position, so responses are safe to cache with the token as part of the cache key. Because the signature covers the exact keyset, an intermediary cache cannot be poisoned by a forged variant — a tampered token never reaches your handler’s query, it is rejected at verification. Set Cache-Control: private, max-age=60 on deep pages, and rotate CURSOR_SECRET with an overlap window (accept the previous secret during rotation) so in-flight tokens do not all invalidate at once. Truncating the HMAC to 16 bytes is a deliberate size/security trade; do not truncate below 12 bytes.

SDK and Codegen Downstream Effect

Because next_cursor is a nullable string, generators emit next_cursor?: string. The one failure mode to guard against is a generator that drops nullable: true and types the field as a required string, producing SDK code that dereferences null on the final page. A generated pagination helper should treat any decode/verify 400 as “start over”, not as a fatal error:

// Generated-client wrapper
export async function* iterate(client: Api, limit = 50) {
  let cursor: string | undefined;
  while (true) {
    const { data, page_info } = await client.list({ limit, cursor });
    yield* data;
    if (!page_info.has_next_page || !page_info.next_cursor) break;
    cursor = page_info.next_cursor; // opaque: never inspected
  }
}

Common Mistakes

Mistake Correct approach
Putting raw JSON or standard base64 in the URL Use base64url (RFC 4648 §5); it needs no percent-encoding
Unsigned cursors the server trusts blindly HMAC-SHA256 the payload; verify with a constant-time compare
Comparing signatures with === / == Use timingSafeEqual / hmac.compare_digest to avoid timing leaks
No version field in the payload Add v; branch on it during decode and reject unknown versions with 400
Encoding sensitive keyset values in a token you call “opaque” base64 is not encryption — omit or encrypt fields that must stay secret

FAQ

Why use base64url instead of standard base64 for cursors?

Standard base64 uses +, /, and =, which are reserved or padding characters in a URL query string. When a token containing them lands in ?cursor=, it gets percent-encoded (or silently corrupted by a proxy), which either bloats the token or breaks it. Base64url, defined in RFC 4648 §5, substitutes - for + and _ for / and lets you drop the = padding, so the token is safe to drop into a query parameter with no further escaping.

Do I need to sign or encrypt cursor tokens?

Sign always; encrypt only if the payload is sensitive. An HMAC-SHA256 signature lets the server detect tampering, so a client cannot forge a cursor that jumps to arbitrary rows or sidesteps an authorization filter baked into the keyset. Encryption is needed only when the keyset values themselves must stay secret from the client — base64 is encoding, not encryption, and hides nothing on its own. For most APIs, signing plus omitting sensitive columns from the payload is enough.

How do I keep old cursors working after a schema change?

Put a version integer (v) in the payload and branch on it during decode. When the keyset columns change — say you add a tiebreaker or switch the sort key — bump the version and keep a decoder for the previous version for as long as issued tokens might still be in flight (your cache TTL plus a margin). Reject genuinely unknown versions with 400 so clients fetch a fresh first page instead of silently receiving wrong results.