AboutExpertiseWorkR&DBlogToolsStartContact

A cookie banner you can prove, with Azure Tables and Functions

The banner you copy from a vendor can itself be a tracker. Build your own instead, with no third parties, and the hard part turns out not to be the banner: it is keeping the proof that the person consented. Here is all of it, from the browser to the Azure Table, step by step.

pH7x Systems® · · 8 min read

A website needs to ask for cookie consent. The easy answer is to paste in a vendor's widget, which solves the banner and creates a bigger problem: that widget can itself act as one more tracker, and the visitor's choice ends up stored on the vendor's servers, not ours.

A banner of your own, with no third parties, is worth more, and the hard part is not the banner. It is the proof. The GDPR, in article 7(1), does not merely require that consent be asked for: it requires that you be able to demonstrate that the person consented. Here is the whole configuration, from the browser to the Azure Table, step by step.

The real problem: consent you cannot prove

Consent that lives only in the visitor's localStorage cannot be demonstrated. We do not hold it, we cannot show it to anyone, and the person can delete it whenever they like. Faced with an audit, saying "we have consent" without being able to show it counts for nothing.

The choice has to be recorded on our side too, with a date and a policy version. This is where Azure Functions and Azure Tables come in: the Function receives the choice, the Table keeps it, and that Table is what we produce when someone asks what was consented to, by whom, and when.

One banner, and no third-party script in the HTML

The rule that is not up for negotiation: no third-party <script> lives in the HTML. They are all injected by our own code, and only after the "yes". ePrivacy requires consent before the cookie, and a Google tag pasted into the HTML would already have set one before anyone clicked.

html
<!-- No third-party <script> lives in the page. They are all injected by
     consent.js, and only after "yes". A Google tag here would set a cookie
     before anyone clicked. -->
<div class="cb" id="cb">
  <button type="button" class="cb-pill" id="cbPill" aria-expanded="false">
    Cookies
  </button>
  <div class="cb-card" id="cbCard" role="dialog" hidden>
    <ul class="cb-cats">
      <li><strong>Necessary</strong> <span>always on</span></li>
      <li><label><strong>Statistics</strong>
        <input type="checkbox" id="cbStat"></label></li>
    </ul>
    <div class="cb-acts">
      <button type="button" id="cbNone">Reject all</button>
      <button type="button" id="cbSave">Save</button>
      <button type="button" id="cbAll">Accept all</button>
    </div>
  </div>
</div>

By default, everything is denied, with no pre-ticked boxes. And "Accept all" and "Reject all" carry the same visual weight: a loud accept next to a faded reject is not free choice, it is a manipulative pattern, the kind regulators have fined.

Consent Mode v2: deny first, grant what was accepted

Google Analytics does not load before consent. When the Statistics category is accepted, we inject gtag and use Consent Mode v2: all four signals start denied, and only then do we grant what the person accepted. We sell no advertising, so the three ad signals stay denied for good, even on "Accept all".

js
function gtag() { window.dataLayer.push(arguments); }

function enableAnalytics() {
  if (window.__ga) return;
  window.__ga = 1;
  window.dataLayer = window.dataLayer || [];
  window.gtag = gtag;

  // Consent Mode v2: deny all four signals before gtag.js loads, then grant
  // only what was accepted. We sell no ads, so the three ad signals stay
  // denied for good, even on "Accept all".
  gtag("consent", "default", {
    ad_storage: "denied", ad_user_data: "denied",
    ad_personalization: "denied", analytics_storage: "denied",
  });
  gtag("consent", "update", { analytics_storage: "granted" });

  const s = document.createElement("script");
  s.async = true;
  s.src = "https://www.googletagmanager.com/gtag/js?id=" + GA_ID;
  document.head.appendChild(s);

  gtag("js", new Date());
  gtag("config", GA_ID);
}

Order matters: deny before gtag.js loads, and grant afterwards. The other way round, there would be a window in which Analytics ran without consent, which is exactly what Consent Mode exists to prevent.

The identifier, and sending the proof

The link between the person and the record is a random identifier, generated in the browser and kept in the visitor's localStorage. They are the one who holds it: if they ask us to show what they consented to, or to erase it, this id is how the record is found. We do not keep the IP address, because keeping it in order to prove consent would itself require consent.

js
// A random id, generated here and kept in the visitor's localStorage. It is
// the only link between the person and the server-side record, and they hold
// it: to prove or to erase, this id is how the record is found. No IP is kept.
function visitorId() {
  let id = localStorage.getItem("consent-id");
  if (id && /^[0-9a-f]{32}$/.test(id)) return id;
  const b = new Uint8Array(16);
  crypto.getRandomValues(b);
  id = [...b].map((x) => ("0" + x.toString(16)).slice(-2)).join("");
  localStorage.setItem("consent-id", id);
  return id;
}

function record(choice) {
  // Fails in silence on purpose: the choice already lives in localStorage.
  // What is lost on an error is our proof, not the visitor's right.
  fetch("/api/v1/consent", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    keepalive: true,                 // survives the tab closing right after
    body: JSON.stringify({ id: visitorId(), v: 1, stat: choice.stat, lang }),
  }).catch(() => {});
}

keepalive lets the request survive someone who clicks and then closes the tab straight away. And the send fails silently on purpose: the choice is already in localStorage, so what is lost on an error is our proof, not the person's right. You never break the page of someone who is reading.

The proof: an append-only table

On the server side there is an Azure Function that receives the choice and writes it to the Table. The detail that makes this proof rather than telemetry is that it is append-only: each choice is a new event, with partitionKey on the visitor's id and rowKey on the timestamp. Nothing is ever rewritten.

ts
import { app, HttpRequest, HttpResponseInit } from "@azure/functions";
import { TableClient } from "@azure/data-tables";
import { DefaultAzureCredential } from "@azure/identity";

const NO_CONTENT: HttpResponseInit = { status: 204 };
const VALID_ID = /^[0-9a-f]{32}$/;

function table(): TableClient {
  // No account key anywhere: shared keys are disabled on the storage account,
  // and the Function's managed identity holds the Table Data Contributor role.
  return new TableClient(
    `https://${process.env.STORAGE_ACCOUNT}.table.core.windows.net`,
    "consent",
    new DefaultAzureCredential(),
  );
}

async function handler(req: HttpRequest): Promise<HttpResponseInit> {
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
  const id = body && typeof body.id === "string" && VALID_ID.test(body.id) ? body.id : null;
  if (!id) return NO_CONTENT;

  // Append-only, and this is what makes it proof. The first version used
  // upsert: changing your mind overwrote the record, and erased the history.
  // Each choice is a new event. Nothing is rewritten, nothing is deleted:
  // the current choice is the latest event, the history is the proof.
  const now = new Date().toISOString();
  await table()
    .createEntity({
      partitionKey: id,          // one person's history reads from one partition
      rowKey: now,               // the instant: every choice is an immutable event
      version: Number(body.v),
      stat: body.stat === true,
      lang: String(body.lang || "en"),
    })
    .catch(() => {});            // never break the page of someone just reading

  return NO_CONTENT;
}

app.http("consent", { methods: ["POST"], authLevel: "anonymous", route: "v1/consent", handler });

The first version used upsert, so changing your mind overwrote the record and erased the history. If someone consented on day 1 and withdrew on day 5, only the "no" remained, and we could no longer show that we had consent when Analytics ran on days 1 to 4. With partitionKey on the id, one person's history reads out of a single partition, which is exactly what an access request under article 15 needs.

No keys, and no IP

The Function has no account key anywhere. Shared keys are disabled on the storage account, and the Function's managed identity holds the Storage Table Data Contributor role, scoped to that account and to no other. The secret that does not exist cannot leak.

powershell
# The Function's managed identity gets exactly one data-plane role, on one account.
az role assignment create `
  --assignee $principalId `
  --role "Storage Table Data Contributor" `
  --scope $storageAccountId

# And the account key stops being a way in at all.
az storage account update `
  --name $storageAccount --resource-group $rg `
  --allow-shared-key-access false

DefaultAzureCredential does the rest: in the cloud it uses the managed identity, on our machine it uses the az login session. The Function also validates the id against a 32-character hex pattern before writing, because the id arrives from the browser and has to look like one of ours, not like an attempt at injection.

The button that never disappears

Consent you cannot withdraw is not valid, and withdrawing has to be as easy as consenting. That is why the cookie button stays visible in a corner at all times: what opens and closes is the preferences card, never the button. We once made the button disappear after the choice was made, and then there was no way to reopen it and change your mind. The button is the withdrawal mechanism, and removing it removed the right.

The choice expires after 12 months, and is then asked again. Closing the card without deciding does not count as consent, and clicking outside it only closes it once a choice has been recorded. These are small details, and each one is the difference between consent that is valid and consent that does not hold up.

What it costs, and what it proves

A Table Storage costs cents a month, and a Function on the consumption plan costs almost nothing at this volume. For that price we got a banner that depends on nobody, that lets not a single cookie drop before the "yes", and above all a record we can open and read on the day someone asks. The banner is the part you see. The proof is the part that counts.

Comments

Keep reading