Ein Cookie-Banner, das sich beweisen lässt, mit Azure Tables und Functions
Das Banner, das man von einem Anbieter kopiert, ist selbst ein Tracker. Ein eigenes Banner, ohne Dritte, und der schwierige Teil ist nicht das Banner: Es ist, den Nachweis zu behalten, dass die Person zugestimmt hat. Hier ist alles, vom Browser bis zur Azure Table, Schritt für Schritt.
Eine Website muss um Cookie-Zustimmung bitten. Die einfache Lösung ist, das Widget eines Anbieters einzukleben, das das Banner löst und ein größeres Problem bringt: Dieses Widget ist oft ein Tracker mehr, und die Wahl der Person landet in seinem Haus gespeichert, nicht in unserem.
Ein eigenes Banner, ohne Dritte, lohnt sich mehr, und der schwierige Teil ist nicht das Banner. Es ist der Nachweis. Die DSGVO verlangt in Artikel 7 Absatz 1 nicht nur, dass man um Zustimmung bittet: Sie verlangt, dass man nachweisen kann, dass die Person zugestimmt hat. Hier ist die ganze Konfiguration, vom Browser bis zur Azure Table, Schritt für Schritt.
Das eigentliche Problem: eine Zustimmung, die sich nicht beweisen lässt
Eine Zustimmung, die nur im localStorage des Besuchers lebt, lässt sich nicht nachweisen. Wir haben sie nicht, wir können sie niemandem zeigen, und die Person kann sie jederzeit löschen. Vor einer Prüfung ist es null wert, «wir haben die Zustimmung» zu sagen, ohne sie zeigen zu können.
Die Wahl muss auch auf unserer Seite festgehalten werden, mit einem Datum und einer Richtlinienversion. Hier kommen die Azure Functions und die Azure Tables ins Spiel: Die Function empfängt die Wahl, die Table bewahrt sie, und diese Table ist es, mit der wir antworten, wenn jemand fragt, was zugestimmt wurde, von wem, und wann.
Ein einziges Banner, und kein Fremdskript im HTML
Die Regel, die nicht verhandelbar ist: Kein Fremd-<script> steht im HTML. Sie werden alle von unserem eigenen Code injiziert, und erst nach dem «Ja». Die ePrivacy verlangt eine Zustimmung vor dem Cookie, und ein ins HTML geklebtes Google-Tag hätte es bereits gesetzt, bevor jemand geklickt hat.
<!-- 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>
Standardmäßig alles verweigert, ohne vorangekreuzte Kästchen. Und «Alle akzeptieren» und «Alle ablehnen» haben dasselbe visuelle Gewicht: ein grelles Akzeptieren neben einem blassen Ablehnen ist keine freie Wahl, es ist ein manipulatives Muster, und es hat denen, die es einsetzten, bereits Bußgelder gekostet.
Consent Mode v2: zuerst verweigern, gewähren, was akzeptiert wurde
Google Analytics kommt nicht vor der Zustimmung herein. Wird die Kategorie Statistik akzeptiert, injizieren wir gtag und verwenden den Consent Mode v2: Die vier Signale starten verweigert, und erst danach wird gewährt, was die Person akzeptiert hat. Wir verkaufen keine Werbung, daher bleiben die drei Werbesignale für immer verweigert, auch bei «Alle akzeptieren».
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);
}
Die Reihenfolge zählt: verweigern, bevor gtag.js lädt, und danach gewähren. Andersherum gäbe es ein Fenster, in dem Analytics ohne Zustimmung liefe, was genau das ist, was der Consent Mode verhindern soll.
Der Bezeichner, und das Senden des Nachweises
Die Verbindung zwischen der Person und dem Datensatz ist ein zufälliger Bezeichner, im Browser erzeugt und im localStorage des Besuchers behalten. Sie ist es, die ihn in der Hand hält: Bittet sie uns, nachzuweisen, wozu sie zugestimmt hat, oder zu löschen, wird der Datensatz über diese id gefunden. Die IP wird nicht behalten, denn sie zum Beweis einer Zustimmung zu behalten, würde eine Zustimmung verlangen, um sie zu behalten.
// 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(() => {});
}
Das keepalive lässt die Anfrage denjenigen überleben, der klickt und den Tab gleich danach schließt. Und das Senden schlägt absichtlich still fehl: Die Wahl liegt bereits im localStorage, daher ist das, was bei einem Fehler verloren geht, unser Nachweis, nicht das Recht der Person. Man zerbricht nie die Seite von jemandem, der gerade liest.
Der Nachweis: eine Append-only-Tabelle
Auf der Serverseite gibt es eine Azure Function, die die Wahl empfängt und in die Table schreibt. Und das Detail, das daraus einen Nachweis macht und keine Telemetrie, ist, dass sie append-only ist: Jede Wahl ist ein neues Ereignis, mit partitionKey auf der id des Besuchers und rowKey auf dem Zeitpunkt. Nichts wird überschrieben.
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 });
Die erste Version nutzte upsert, und die Meinung zu ändern ersetzte den Datensatz und löschte die Geschichte. Stimmte jemand an Tag 1 zu und widerrief an Tag 5, blieb nur das «Nein», und wir konnten nicht mehr nachweisen, dass wir die Zustimmung hatten, als Analytics an den Tagen 1 bis 4 lief. Mit partitionKey auf der id liest sich die Geschichte einer Person aus einer einzigen Partition, was ein Auskunftsersuchen nach Artikel 15 braucht.
Ohne Schlüssel, und ohne IP
Die Function hat nirgendwo einen Kontoschlüssel. Geteilte Schlüssel sind auf dem Speicherkonto deaktiviert, und die verwaltete Identität der Function trägt die Rolle Storage Table Data Contributor, auf dieses Konto und auf kein anderes beschränkt. Das Geheimnis, das nicht existiert, entweicht nicht.
# 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
Das DefaultAzureCredential erledigt den Rest: in der Cloud nutzt es die verwaltete Identität, auf unserer Maschine die az login-Sitzung. Die Function prüft die id außerdem gegen ein Muster aus 32 Hexadezimalzeichen, bevor sie schreibt, denn die id kommt aus dem Browser und muss wie eine id von uns aussehen, nicht wie ein Ort zum Injizieren.
Der Button, der nie verschwindet
Eine Zustimmung, die man nicht widerrufen kann, ist nicht gültig, und der Widerruf muss so leicht sein wie die Zustimmung. Deshalb bleibt der Cookie-Button jederzeit sichtbar in einer Ecke: Was sich öffnet und schließt, ist die Präferenzkarte, nie der Button. Wir haben einmal versucht, den Button nach der Wahl verschwinden zu lassen, und danach gab es keine Möglichkeit, ihn wieder zu öffnen, um die Meinung zu ändern. Der Button ist der Widerrufsmechanismus, und ihn zu löschen zerbrach das Recht.
Die Wahl läuft nach 12 Monaten ab, und dann wird sie erneut gestellt. Die Karte zu schließen, ohne zu entscheiden, zählt nicht als Zustimmung, und ein Klick daneben schließt sie nur, wenn bereits eine Wahl festgehalten ist. Es sind kleine Details, und jedes einzelne ist der Unterschied zwischen einer gültigen Zustimmung und einer, die nicht hält.
Was es kostet, und was es beweist
Eine Table Storage kostet Cent im Monat, und eine Function im Verbrauchsplan bei diesem Volumen fast nichts. Für diesen Preis haben wir ein Banner, das von niemandem abhängt, das kein einziges Cookie vor dem «Ja» fallen lässt, und vor allem einen Datensatz, der sich an dem Tag öffnet und liest, an dem jemand fragt. Das Banner ist der Teil, den man sieht. Der Nachweis ist der Teil, der zählt.



