Consent

beta

The cookie contract between a site's own banner and the gateway underneath it — Consent Mode v2 vocabulary, a server-set mirror that outlives the browser cap, and the sources behind every rule.

Consent

Every site behind doorman will build its own consent banner. It has to: the banner is part of the product, in the product's language, in the product's design. Doorman does not want to render it.

But doorman sits underneath that banner, and there is exactly one thing it needs to know from it — may I remember this visitor between visits? So this is a contract, not a component: your banner writes one cookie in an agreed format, and doorman reads it. Four words of JavaScript, and the gateway starts behaving.

// after the visitor accepts
document.cookie =
  "doorman_consent=v=1&analytics_storage=granted&ts=" +
  Math.floor(Date.now() / 1000) + ";path=/;max-age=15552000;samesite=lax";

Nothing else changes. No script to load, no callback to register, no SDK.

The cookie

Namedoorman_consent — configurable per site as analytics.consent.cookie
Valuea query string: v=1&analytics_storage=granted&ts=1755000000
Written byyour site, at whatever lifetime your policy says
Read bydoorman, on every page view
Never written bydoorman — it owns a separate mirror, below

The value is a query string rather than JSON or base64 for a boring, load-bearing reason: RFC 6265's cookie-octet grammar permits both & and = in a cookie value, so no encoding layer is needed anywhere.1 A site can write it from a template string and read it with URLSearchParams. Percent-encoding the value works too — doorman unescapes on read — but nothing requires it.

ts is unix seconds and is the visitor's decision time. Leave it out and the decision is treated as always fresh, which is worse for everyone; a stamp more than a day in the future is a broken writer and the signal is discarded.

A malformed value is no signal, never a guess. When the question is "may I store something on this person's machine", the only safe parse failure is the one that answers no.

The purpose names are not ours

analytics_storage, ad_storage, ad_user_data and ad_personalization, with the values granted and denied, are Google Consent Mode v2's vocabulary.2 That is deliberate. Every consent management platform on the market already emits those four signals, so a site with a CMP maps fields it already has instead of learning a doorman-specific dialect, and a site with a hand-rolled banner writes four words that mean the same thing everywhere else it will ever need them.

Doorman acts on exactly one of them — analytics_storage — because that is the only thing it does. The others are read, kept and mirrored back untouched, along with any purpose name doorman has never heard of. Doorman is not the authority on what a site asks its visitors; only on what it may do with the answer.

We did not adopt IAB TCF. It is a full adtech consent framework with a vendor list, a binary string and a compliance programme attached, aimed at real-time bidding. For "may I set one first-party cookie" it is several orders of magnitude too much machinery.

What consent actually switches on

This is the part worth being precise about, because it is the opposite of the usual arrangement.

Without consentWith analytics_storage=granted
Page views, sessions, top pagescountedcounted
Daily unique visitorscountedcounted
Stored on the devicenothingone first-party cookie, dm_v
Returning visitors, cohortsnot availableavailable

Consent does not switch on the measurement. It switches on the memory.

With no signal at all, a visitor is counted by a cookieless daily hash — a rotating-salt construction with nothing written to their device — and doorman is in the same position as Plausible or Umami, which is the position that does not require a banner in the first place.3 Consent buys precisely one thing: the persistent identifier that makes "did this person come back" a question with an answer.

That distinction is also the answer to the standard criticism of gateway-mounted tag proxies. When a gateway runs underneath the page, a site whose consent logic merely decides whether to load a tag has no way to reach it — the gateway already saw the request. Doorman's default is fail-closed: no signal means no persistent identifier, so the thing a visitor could object to never happens by default. There is a test named after this (No_consent_means_no_identifier_even_though_the_site_asked_for_devices), and it would fail loudly if the default ever inverted.

The legal shape is the same distinction. What needs consent under Art. 5(3) of the ePrivacy Directive is storing information on, or gaining access to information stored in, terminal equipment — not counting a request that was made to you anyway.4 Doorman's default stores nothing; the device rung stores something, and waits.

The two cookies

Doorman owns two cookies of its own. Both are HttpOnly, SameSite=Lax, Secure over HTTPS, and scoped to the site's host — never to a shared parent domain, for the same reason one customer's PIN must not unlock another customer's site.

CookiePurposeLifetime
dm_cthe mirror: doorman's server-set copy of your consent cookiemirrorDays, default 180
dm_vthe device identifier — 16 random bytes, base64url400 days

Why a mirror exists

Safari's Intelligent Tracking Prevention caps script-writeable storage at seven days. A cookie set through document.cookie — which is how every consent banner sets one — is expired a week later regardless of the max-age you gave it.5 So the banner's own memory of "I already asked, and they said no" evaporates, and next week it asks again.

The cap attaches to how the cookie was created, not to the domain. A cookie in a Set-Cookie header from the server the browser is actually talking to is not capped, and can live up to the 400-day ceiling browsers now apply.6 Doorman is that server: it terminates the request and proxies onward, so there is no CNAME cloaking, no third-party host and none of the first-two-octet mismatches ITP also penalises. When your banner writes doorman_consent, doorman writes dm_c with the same content from the server side, and the decision survives.

Precedence is simple and always the same direction: your cookie wins whenever it is there. The mirror is only consulted when the site's own cookie is missing — that is, when it has evaporated. A visitor who changes their mind is obeyed on the very next request, not at the next expiry.

The mirror is renewed symmetrically

A denied is mirrored on exactly the same terms as a granted — same lifetime, same attributes, byte for byte. This is the rule most implementations get wrong, and getting it wrong is a dark pattern with a technical alibi: remember "yes" for six months, forget "no" every week, and re-ask until you get the answer you wanted. There is a test that compares the two Set-Cookie attribute sets for equality (A_refusal_is_mirrored_on_exactly_the_same_terms_as_a_permission).

Withdraw by setting denied, never by deleting

document.cookie =
  "doorman_consent=v=1&analytics_storage=denied&ts=" +
  Math.floor(Date.now() / 1000) + ";path=/;max-age=15552000;samesite=lax";

Deleting the cookie means no answer, and no answer is a state doorman must treat as "ask again" — so the banner reappears and nothing is erased. Setting denied is the actual withdrawal, and it does three things on the next request: the device cookie is deleted, the device's record is erased from the visitor index, and the refusal is mirrored so it is still refused after the browser forgets your copy.

honourDays (default 365) is the far end of it. A decision older than that stops being honoured, the mirror stops being renewed, and your banner comes back on its own — because a consent nobody can date is not a consent.

Checking it works

doorman site set griller analytics.enabled=true analytics.identity=device

# -D - keeps the headers; Accept: text/html is what makes it a page view rather
# than an asset request, and a HEAD is never one.
curl -s -o /dev/null -D - https://griller.example/ \
  -H 'Accept: text/html' \
  -H 'Cookie: doorman_consent=v=1&analytics_storage=granted&ts=1755000000' \
  | grep -i set-cookie
# set-cookie: dm_c=...; expires=...; path=/; secure; httponly; samesite=lax
# set-cookie: dm_v=...; expires=...; path=/; secure; httponly; samesite=lax

doorman stats griller --cohorts

No dm_v in that output with identity=device set means the signal did not parse — check that analytics_storage is spelled exactly, that the value is granted, and that ts is seconds rather than milliseconds.

Sources

Every rule above came from somewhere. They are listed so that a site implementing against this contract can check the reasoning rather than take the rules on faith — and so that whoever revisits this when a browser changes its mind knows which claim to re-verify.

Footnotes

  1. RFC 6265 §4.1.1, the cookie-octet grammar — & and = are permitted in a cookie value. https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1

  2. Google Consent Mode v2 — the four purpose signals (ad_storage, analytics_storage, ad_user_data, ad_personalization) and their granted/denied values. https://developers.google.com/tag-platform/security/guides/consent

  3. Plausible's data policy and Umami's equivalent — a daily rotating salt over IP and user agent, producing an identifier that cannot be recomputed after the salt is destroyed. This is the construction doorman's session rung uses. https://plausible.io/data-policy

  4. EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) of the ePrivacy Directive — consent attaches to storing or accessing information on terminal equipment, which is why storing nothing needs no banner and storing a cookie does.

  5. WebKit, "Full Third-Party Cookie Blocking and More" — the seven-day cap on all script-writeable storage, including cookies set via document.cookie. https://webkit.org/blog/10218/

  6. The 400-day upper bound on cookie lifetime (RFC 6265bis, shipped by Chrome and others) — the ceiling a server-set first-party cookie is actually subject to, as against ITP's seven days for script-written ones. https://developer.chrome.com/blog/cookie-max-age-expires