AboutExpertiseWorkBlogCredentialsContact
← Back to the blog Governance & security

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

The banner you copy from a vendor is itself a tracker. A banner of your own, with no third parties, and the hard part is not 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 solution is to paste a vendor's widget, which solves the banner and brings a bigger problem: that widget is often one more tracker, and the person's choice ends up stored in its house, 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 only require that consent be asked: 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 have it, we cannot show it to anyone, and the person can delete it whenever they want. In front of an audit, saying «we have consent» without being able to show it is worth zero.

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 it is that Table we answer with when someone asks what was consented, 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 prior to the cookie, and a Google tag pasted into the HTML would already have set it 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 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, and it has already cost fines to those who used it.

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: the 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 around 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 demonstrate what they consented to, or to erase it, this id is how the record is found. The IP is not kept, because keeping it to prove a consent would require consent to keep it.

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 makes the request survive someone who clicks and closes the tab right after. And the send fails in silence 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. And the detail that makes this proof, and not telemetry, is that it is append-only: each choice is a new event, with partitionKey on the visitor's id and rowKey on the instant. Nothing is 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, and changing your mind overwrote the record, erasing the history. If someone consented on day 1 and withdrew on day 5, only the «no» remained, and we could no longer demonstrate that we had consent when Analytics ran on days 1 to 4. With partitionKey on the id, one person's history reads from a single partition, which is 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-hex pattern before writing, because the id comes from the browser and has to look like an id of ours, not like a place to inject.

The button that never disappears

A consent you cannot withdraw is not valid, and the withdrawal has to be as easy as the consent. 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 tried to make the button vanish after the choice, and then there was no way to reopen it to change your mind. The button is the withdrawal mechanism, and deleting it broke the right.

The choice expires at 12 months, and then it is asked again. Closing the card without deciding does not count as consent, and clicking outside only closes it when a choice is already recorded. These are small details, and each one is the difference between a valid consent and one 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 almost nothing for this volume. For that price we got a banner that depends on no one, that lets not a single cookie drop before the «yes», and above all a record that opens and reads on the day someone asks. The banner is the part you see. The proof is the part that counts.

Keep reading