Sign the encoded payload with HMAC-SHA256 and keep the key on the server. The token on the wire is base64url(payload) + "." + base64url(mac). Sign the base64url payload bytes themselves, not the JSON, so the exact string a client sends back is the exact string you verify. Use a constant-time comparison; a naive == comparison on a MAC is a timing side channel.
import base64, hmac, hashlib, json, time
def b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def b64d(text: str) -> bytes:
pad = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + pad)
def encode_cursor(keys, filter_hash, direction, ttl, key_id, secret):
payload = {"v": 1, "k": keys, "f": filter_hash, "d": direction,
"e": int(time.time()) + ttl, "kid": key_id}
body = json.dumps(payload, separators=(",", ":")).encode()
token = b64e(body)
mac = hmac.new(secret, token.encode(), hashlib.sha256).digest()
return f"{token}.{b64e(mac)}"
def decode_cursor(token, secrets_by_id):
token_b64, mac_b64 = token.split(".")
payload = json.loads(b64d(token_b64))
secret = secrets_by_id.get(payload["kid"])
if secret is None:
raise ValueError("unknown key id")
expected = hmac.new(secret, token_b64.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64d(mac_b64)):
raise ValueError("bad signature")
if payload["e"] < time.time():
raise ValueError("cursor expired")
return payload
Two decisions are worth writing down. First, include the key id in the signed payload so rotation can run without a flag day: old cursors verify with the old key, new cursors are issued with the new one. Second, reject a filter hash mismatch before you touch the database. This makes cursors idempotent under retries and safe to treat as bears of state.
A forged cursor in the offset world lets a client request an arbitrary page number. A forged cursor here lets a client request arbitrary sort key values, which can be worse: it can skip ahead to records the user should not see if the sort key aligns with a cheaper query path, or it can pass a filter hash that no longer matches the row-level policy. That is why cursors are opaque. Clients must not parse, decode, or rebuild them, and your SDK should not encourage it.
For cursor errors, return a standard problem detail body rather than an ad hoc error shape. RFC 9457 defines a JSON problem detail with an application/problem+json media type that machines can parse RFC 9457, and the Swagger guide explains why inventing a custom error format costs integration time and money the Swagger problem details guide. A cursor that fails verification should come back with a stable type URI such as https://api.example.com/problems/invalid-cursor, so every SDK can map it to the same retry decision.