The ARX specification
09 The paging cookie
Providers with TokenPaging return, with each page that has a next one, an opaque **cookie** the caller passes back for the next page. Its format is shared, so a cookie is checked the same way everywhere: it belongs to one provider and one query, and may be signed.
Format (version 1)
The cookie is the base64url encoding (RFC 4648 ยง5, without padding) of these bytes:
| Bytes | Field |
|---|---|
| 1 | Version: 1 |
| 1 | Signed: 1 when signed, 0 otherwise |
| 1 | P, the length of the provider name in UTF-8 bytes (1 to 64) |
| P | The provider name, UTF-8 |
| 16 | The query fingerprint: the first 16 bytes of SHA-256 over the query description, UTF-8 (an empty description when none) |
| any | The provider's state, in its own encoding |
| 32 | When signed: HMAC-SHA256, with the signing key, over every byte before it |
- The query description is the provider's text for what the cookie belongs to: the entity, its filter and order, never the page. Two queries that differ in any of those must have different descriptions.
- A signing key, when used, is at least 32 bytes (shorter is ARX1001).
Reading one back
A cookie must be refused with ARX1001 InvalidArgument, without echoing it, when it is empty or longer than 65,536 characters, isn't base64url, is shorter than its header, has another version, is signed when the reader has no key or unsigned when it has one, fails its signature (compared in constant time), names another provider, or carries another query's fingerprint. Otherwise the state is returned.
vectors/paging-cookie.json has cookies encoded from fixed inputs (each implementation must produce exactly those
strings) and cookies each implementation must refuse.
Protection
A provider protects its cookies one of three ways, and refuses a cookie protected any other way (ARX1001):
| Protection | Format | A client can |
|---|---|---|
| Sealed (the default) | version 2, below | neither read nor change it |
| Signed | version 1, signed | read it, not change it |
| Plain | version 1, unsigned | read and change it; the provider and query checks still apply |
A cookie carries the last item's key values, which can be an email or a tenant id: that is why the default seals it. Without a key from the application, a provider seals with a random key of its process, so every service in the process reads its cookies; another process (a second instance, or after a restart) needs the same key from the application.
Format (version 2, sealed)
| Bytes | Content |
|---|---|
| 1 | Version: 2 |
| 1 | Protection: 2 (sealed) |
| 1 | The provider name's length in UTF-8 bytes, then the name |
| 12 | A random nonce |
| n | AES-256-GCM ciphertext of the query fingerprint (16 bytes) followed by the provider's state |
| 16 | The GCM tag |
The AES key is HKDF-SHA256 of the application's key (at least 32 bytes) with info ARX paging cookie v2 sealed, no
salt, 32 bytes long. The header (version, protection, provider) is the associated data, so none of it can be changed
either. The cookie is base64url without padding, as version 1.
A sealed cookie is refused, without echoing it, when it isn't version 2, names another provider, fails to decrypt (it
was changed, or sealed with another key), or carries another query's fingerprint. vectors/paging-cookie-sealed.json
has sealed cookies to decode (sealing is random, so it can't be encoded to an exact string) and changed ones to refuse.