Une bannière de cookies qui se prouve, avec Azure Tables et Functions
La bannière que l'on copie chez un fournisseur est elle-même un traceur. Mieux vaut la sienne, sans tiers, et la partie difficile n'est pas la bannière : c'est conserver la preuve que la personne a consenti. Voici toute la configuration, du navigateur à l'Azure Table, étape par étape.
Un site web doit demander le consentement aux cookies. La solution facile consiste à coller le widget d'un fournisseur : il règle la question de la bannière et en apporte une plus grave, car ce widget peut lui-même se comporter comme un traceur de plus, et le choix de la personne finit stocké chez lui, pas chez nous.
Une bannière maison, sans tiers, vaut mieux, et la partie difficile n'est pas la bannière : c'est la preuve. Le RGPD, à l'article 7, paragraphe 1, n'exige pas seulement de recueillir le consentement : il exige d'être en mesure de démontrer que la personne a consenti. Voici toute la configuration, du navigateur à l'Azure Table, étape par étape.
Le vrai problème : un consentement qu'on ne prouve pas
Un consentement qui ne vit que dans le localStorage du visiteur ne se démontre pas. Nous ne l'avons pas, nous ne pouvons le montrer à personne, et l'intéressé peut l'effacer quand il le souhaite. Face à un audit, dire « nous avons le consentement » sans pouvoir le prouver ne vaut rien.
Le choix doit donc être enregistré de notre côté également, avec une date et une version de la politique. C'est là qu'interviennent les Azure Functions et les Azure Tables : la Function reçoit le choix, la Table le conserve, et c'est cette Table que nous produisons lorsque quelqu'un demande ce qui a été consenti, par qui et quand.
Une seule bannière, et aucun script tiers dans le HTML
La règle qui ne se négocie pas : aucun <script> tiers ne figure dans le HTML. Ils sont tous injectés par notre propre code, et seulement après le « oui ». La directive ePrivacy exige un consentement préalable au dépôt du cookie, et une balise Google collée dans le HTML l'aurait déposé avant même que quiconque ait cliqué.
<!-- 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>
Par défaut, tout est refusé, sans case précochée. Et « Tout accepter » et « Tout refuser » ont le même poids visuel : un bouton d'acceptation éclatant à côté d'un refus terne n'est pas un choix libre, c'est un schéma manipulateur, du genre que les régulateurs ont déjà sanctionné.
Consent Mode v2 : refuser d'abord, accorder ce qui a été accepté
Google Analytics n'entre pas en scène avant le consentement. Quand la catégorie Statistiques est acceptée, nous injectons gtag et utilisons le Consent Mode v2 : les quatre signaux partent refusés, et l'on n'accorde qu'ensuite ce que la personne a accepté. Nous ne vendons pas de publicité : les trois signaux publicitaires restent donc refusés en permanence, même sur un « Tout accepter ».
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);
}
L'ordre compte : refuser avant le chargement de gtag.js, et accorder après. Dans l'autre sens, il resterait une fenêtre pendant laquelle Analytics tournerait sans consentement, c'est-à-dire précisément ce que le Consent Mode sert à empêcher.
L'identifiant, et l'envoi de la preuve
Le lien entre la personne et l'enregistrement est un identifiant aléatoire, généré dans le navigateur et conservé dans le localStorage du visiteur. C'est la personne qui le détient : si elle nous demande de démontrer ce à quoi elle a consenti, ou de l'effacer, c'est par cet identifiant que l'on retrouve l'enregistrement. L'adresse IP, elle, n'est pas conservée, car la conserver pour prouver un consentement supposerait un consentement pour la conserver.
// 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(() => {});
}
Le keepalive permet à la requête de survivre au visiteur qui clique puis ferme l'onglet dans la foulée. Et l'envoi échoue en silence à dessein : le choix se trouve déjà dans le localStorage, si bien qu'en cas d'erreur, ce qui se perd est notre preuve, pas le droit de la personne. On ne casse jamais la page de quelqu'un qui est en train de lire.
La preuve : une table append-only
Côté serveur, une Azure Function reçoit le choix et l'écrit dans la Table. Le détail qui fait de tout cela une preuve, et non de la télémétrie, c'est que la table est append-only : chaque choix constitue un nouvel événement, avec la partitionKey sur l'identifiant du visiteur et la rowKey sur l'instant. Rien n'est réécrit.
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 });
La première version utilisait upsert : changer d'avis remplaçait l'enregistrement et effaçait l'historique. Si quelqu'un consentait le premier jour et se rétractait le cinquième, il ne restait que le « non », et nous ne pouvions plus démontrer que nous avions le consentement pendant qu'Analytics tournait, du premier au quatrième jour. Avec la partitionKey sur l'identifiant, l'historique d'une personne se lit dans une seule partition, ce qu'exige précisément une demande d'accès au titre de l'article 15.
Sans clés, et sans IP
La Function ne détient aucune clé de compte, nulle part. Les clés partagées sont désactivées sur le compte de stockage, et l'identité managée de la Function porte le rôle Storage Table Data Contributor, limité à ce seul compte. Un secret qui n'existe pas ne fuit pas.
# 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 fait le reste : dans le cloud, il utilise l'identité managée ; sur notre machine, la session az login. La Function valide en outre l'identifiant contre un motif de 32 caractères hexadécimaux avant d'écrire, car cet identifiant vient du navigateur : il doit ressembler à l'un des nôtres, et non à un point d'injection.
Le bouton qui ne disparaît jamais
Un consentement que l'on ne peut pas retirer n'est pas valide, et le retrait doit être aussi simple que le consentement. C'est pourquoi le bouton de cookies reste visible en permanence dans un coin : ce qui s'ouvre et se ferme, c'est la carte de préférences, jamais le bouton. Nous avons essayé une fois de faire disparaître le bouton après le choix, et il n'y avait alors plus aucun moyen de le rouvrir pour changer d'avis. Le bouton est le mécanisme de retrait : le supprimer supprimait le droit.
Le choix expire au bout de 12 mois, après quoi il est redemandé. Fermer la carte sans décider ne vaut pas consentement, et cliquer en dehors ne la ferme que si un choix a déjà été enregistré. Ce sont de petits détails, et chacun fait la différence entre un consentement valide et un consentement qui ne tient pas.
Ce que ça coûte, et ce que ça prouve
Une Table Storage coûte quelques centimes par mois, et une Function sur le plan de consommation presque rien pour ce volume. Pour ce prix, nous avons une bannière qui ne dépend de personne, qui ne dépose pas un seul cookie avant le « oui », et surtout un enregistrement qui s'ouvre et se lit le jour où quelqu'un le demande. La bannière est la partie que l'on voit. La preuve est la partie qui compte.

