The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.
1. Principles
- The anonymous id identifies a browser/device, not a person. Its only valid shape is a random UUID (RFC 4122),
pattern
^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$(case-insensitive). - The client mints; the server never does. Minting happens lazily, only at the moment an event is actually allowed to send (spec-tracker §2) — never at library init, never as an import/startup side effect, never while the consent gate is closed.
- The server's only roles are to persist/prolong an id the client hands it and to expire it on withdrawal.
2. Cookie contract
The id lives in a cookie (not local-only storage) so a future server-owned event could read the same id from the request.
- Name: a token matching
^[A-Za-z0-9_-]+$(conservative subset of the RFC 6265 token). Builders MUST reject any other name. - Value: the UUID.
- Attributes:
Path=/;Max-Agedefaulting to one year (31536000 seconds);SameSite=Lax; neverHttpOnly(the client tracker must read the id back). Client-side writes addSecureon HTTPS origins; server-issued Set-Cookie headers are alwaysSecure(on a plain-HTTP dev origin the browser may drop it — the client-written cookie still covers that session). - Reading a cookie string (
name=value; name2=value2wire format): the name must match exactly; a longer prefix sibling (myidXwhen readingmyid) MUST NOT match.
3. Client-side id manager
Operations and their semantics:
getOrCreate()— return the existing cookie value, else mint a fresh UUID, write the cookie, and return it. The live cookie always wins over any in-memory cache: another tab's withdrawal expires the cookie without notifying this instance, and a re-grant MUST NOT revive the pre-withdrawal identity from memory.refresh()— roll an existing id's expiry forward without ever minting; a no-op for visitors who have no id. Safe to call on page load for returning consented visitors.clear()— expire the cookie (Max-Age 0) and drop any cached copy. Called on consent withdrawal (spec-consent §9).- Implementations MAY cache the id after the first read so a per-event callback does not re-parse the cookie header; the
cache MUST be invalidated by
clear()and never trusted over the live cookie.
4. Server persistence (the ITP re-issue)
Script-written cookies are capped (e.g. 7 days by Safari ITP); a server Set-Cookie re-issue is what lets the id live its full max-age. A server-persisted id manager wraps the client one:
- Optimistic local write first — the current event never waits on a round trip; a failed persist degrades to plain client-side behavior.
- At most one persist request per page load, fired only after an id exists client-side. The re-issue also fires when
the cookie already existed (it upgrades a script-written cookie to a server-set one and rolls expiry forward).
refresh()shares the same once-per-load budget — it MUST NOT add a second request. - If the cookie disappears out-of-band (cross-tab withdrawal), the once-per-load flag resets so the next minted id is
persisted again. Likewise after a local
clear()— a fresh id after a re-grant must be persisted again. - Persist/clear round-trip failures MUST be swallowed (tracking never breaks the app).
clear()also requests the server-side expiry — but only when the cookie actually exists, so a second withdrawal from another mounted consent surface in the same tick costs no extra request.
Server endpoint requirements. The persist endpoint is public and echoes its input into a response header, so:
- It MUST validate the incoming id against the exact UUID pattern (§1) and reject anything else — a non-UUID value is a header-injection attempt or corruption, never a valid id.
- It MUST validate the cookie name against the token pattern (§2).
- On success it answers with a Set-Cookie header persisting the id (§2 attributes); the clear endpoint answers with the
same cookie at
Max-Age=0. - The server MUST only ever re-set an id the client handed it — never generate one.
5. Withdrawal
On a decision with analytics == false: client-side clear() (cookie expiry + cache drop) plus the server-side expiry
per §4. Destination identifier cookies are cleared per spec-destinations §4.
Conformance vectors
V1 — id shape. "6f1c2a4e-9b0d-4c3e-8f5a-1d2e3c4b5a69" valid; uppercase hex valid; "not-a-uuid", "",
"6f1c2a4e-9b0d-4c3e-8f5a-1d2e3c4b5a69; Path=/evil" invalid — the persist endpoint rejects them.
V2 — Set-Cookie build. (name anon_id, id U, default max-age) →
anon_id=U; Path=/; Max-Age=31536000; SameSite=Lax; Secure. Clear →
anon_id=; Path=/; Max-Age=0; SameSite=Lax; Secure. Name bad name → error.
V3 — exact-name cookie read. Cookie string anon_idX=zzz; anon_id=abc read as anon_id → abc; string
anon_idX=zzz → none.
V4 — lazy mint. With the gate closed, no track() call ever creates a cookie. With the gate open, the first
permitted track() mints exactly one id; subsequent events reuse it.
V5 — cross-tab withdrawal. After the cookie is expired externally, getOrCreate() mints a new UUID (never
returns the cached old one), and the persist request fires again for the new id.
V6 — once-per-load persist. Three permitted events on one page load → exactly one persist request. A refresh()
after getOrCreate() on the same load → still one request.
License
Released under the MIT License.