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.
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.
| Name | doorman_consent — configurable per site as analytics.consent.cookie |
| Value | a query string: v=1&analytics_storage=granted&ts=1755000000 |
| Written by | your site, at whatever lifetime your policy says |
| Read by | doorman, on every page view |
| Never written by | doorman — 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.
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.
This is the part worth being precise about, because it is the opposite of the usual arrangement.
| Without consent | With analytics_storage=granted | |
|---|---|---|
| Page views, sessions, top pages | counted | counted |
| Daily unique visitors | counted | counted |
| Stored on the device | nothing | one first-party cookie, dm_v |
| Returning visitors, cohorts | not available | available |
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.
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.
| Cookie | Purpose | Lifetime |
|---|---|---|
dm_c | the mirror: doorman's server-set copy of your consent cookie | mirrorDays, default 180 |
dm_v | the device identifier — 16 random bytes, base64url | 400 days |
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.
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).
denied, never by deletingdocument.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.
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.
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.
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 ↩
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 ↩
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 ↩
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. ↩
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/ ↩
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 ↩