Wire / Cookie Banners / Article
┌── POST 09.02 · Cookie Banners · 8 min read

Proof of Consent With a Self-Hosted CMP: What Klaro and CookieConsent Store, and What You Have to Log Yourself

Klaro stores a bare service-to-boolean map; CookieConsent v3 stores a consent ID and timestamps, but only in the browser. The EDPB says a correct site configuration is not proof. What to record, the hook each library exposes, and a minimal endpoint.

TL;DR

A self-hosted consent library stores the visitor’s choice in the visitor’s browser and nowhere else. Klaro’s cookie is a bare map of service names to booleans: no timestamp, no identifier, no record of which banner text was shown. CookieConsent v3 does better, with a consent ID, two timestamps and a revision number, but it is still only in the browser. The EDPB’s consent guidelines say that pointing at a correctly configured website is not enough to demonstrate consent. This post covers what to record, the hook each library exposes for it, and a minimal endpoint to receive it.

Our CookieConsent and Consent Mode v2 guide noted that the library keeps no server-side consent log and that you build one yourself. This is what that involves. The work is in deciding what goes in the record, not in the code.

What the record is for

The obligation comes from Article 7(1) of the GDPR. The European Data Protection Board’s Guidelines 05/2020 on consent describe it as an explicit obligation on the controller to demonstrate a data subject’s consent, with the burden of proof on the controller. Recital 42, which the guidelines quote, says the controller “should be able to demonstrate that the data subject has given consent to the processing operation.”

The guidelines are specific about what a record should show: how consent was obtained, when it was obtained, and what information the person was given at the time. For a website they suggest retaining information on the session in which consent was expressed, documentation of the consent workflow, and a copy of the information presented. Then the sentence that matters for anyone running an open-source library: “It would not be sufficient to merely refer to a correct configuration of the respective website.”

Two more constraints shape the design. The guidelines say the duty to demonstrate consent “should not in itself lead to excessive amounts of additional data processing”, so the record should hold enough to link a choice to the processing and nothing more. And the obligation lasts as long as the processing does; afterwards, proof should be kept no longer than strictly necessary.

That is not legal advice. It is the specification the record has to meet, and it is more than either library writes down.

What each library stores by itself

Klaro

Klaro 0.7.21, the current npm release as of 2 September 2026, stores consent in a cookie named klaro by default, valid for 365 days. The stored value is exactly this, from saveConsents() in the consent manager:

encodeURIComponent(JSON.stringify(this.consents))
// {"googleAnalytics":true,"youtube":false}

That is the whole record. No timestamp, no identifier, no indication of whether the visitor pressed accept, decline or save, and no reference to which version of the banner text they saw. Klaro’s config has had a version field since 0.7.0, but it is not written into the stored value.

CookieConsent v3

Orest Bida’s library, 3.1.0 on npm as vanilla-cookieconsent, stores considerably more in its cc_cookie, which lasts 182 days by default. The typed cookie value has these fields:

{
  categories: string[],
  services: { [category: string]: string[] },
  revision: number,
  consentId: string,             // UUIDv4
  consentTimestamp: string,      // first consent
  lastConsentTimestamp: string,  // most recent update
  languageCode: string,
  data: any
}

That is most of a consent record already. The problem is where it lives: in the visitor’s browser, on a device you do not control, in a cookie that expires in six months or whenever the visitor clears it. If you are ever asked whether this person consented, to what and when, the answer is not in your hands.

What to send to your server

A record per consent event, containing:

  • A consent identifier. CookieConsent generates one. For Klaro, generate one with crypto.randomUUID() on the first save and keep it in localStorage under your own key, so later changes attach to the same identifier.
  • The choices. Per category or per service, as booleans. Record refusals too: a refusal is a decision you may need to show you honoured.
  • How the choice was made. Accept all, decline all, or a custom selection. Both libraries expose this, in different ways.
  • The revision of the texts. An integer that you bump whenever the banner copy, the purposes or the services change. The texts themselves do not go in the record. Commit them under that revision number, in the repository that deploys the site, and a git tag becomes the “copy of the information presented”.
  • Language, page path, library and version. These describe the workflow the guidelines ask you to document.
  • Two timestamps. The client’s, because the libraries supply one, and the server’s, because the client’s can be wrong.

Leave out what you do not need. A full IP address is personal data in its own right, and the guidelines are explicit that proving consent should not become a reason to collect more.

Klaro: register a watcher

Klaro’s consent manager has a small observer API. watch() takes an object with an update(manager, eventType, data) method, and the manager calls it for three events: consents when the in-memory state changes, saveConsents when a choice is persisted, and applyConsents when scripts are toggled. The one you want is saveConsents, because it fires exactly once per stored decision and carries the data you need:

const manager = klaro.getManager(klaroConfig);

manager.watch({
  update(manager, eventType, data) {
    if (eventType !== 'saveConsents') return;
    recordConsent({
      library: 'klaro/0.7.21',
      consentId: getOrCreateConsentId(),
      how: data.type,          // 'accept', 'decline', 'save' or 'script'
      consents: data.consents, // { serviceName: boolean }
      changes: data.changes,   // only the services that changed
      revision: klaroConfig.version,
      lang: document.documentElement.lang,
      page: location.pathname,
      clientTime: new Date().toISOString()
    });
  }
});

Two details from the source. The type value is the button the visitor pressed: the notice component passes accept, decline or save, and the manager substitutes script when consent was set programmatically. And getManager() caches one manager per storage name, so calling it with the config the UI was rendered from returns the instance the UI writes to. Register the watcher in the same script that sets up Klaro, before the banner can be clicked.

CookieConsent: two callbacks, not three

CookieConsent has three consent callbacks and it is worth being precise about which to use. onFirstConsent fires once, when a visitor first decides, and fires again when the revision number changes and they decide again. onChange fires when they alter their preferences later. onConsent fires on first consent and on every subsequent page load, which is right for restoring Consent Mode state and wrong for logging: wire it to your endpoint and you write one record per page view.

function recordFromCookie(how, cookie, changed) {
  const prefs = CookieConsent.getUserPreferences();
  recordConsent({
    library: 'vanilla-cookieconsent/3.1.0',
    consentId: cookie.consentId,
    how,                              // 'first' or 'change'
    acceptType: prefs.acceptType,     // 'all', 'custom' or 'necessary'
    categories: cookie.categories,
    services: cookie.services,
    changed: changed || [],
    revision: cookie.revision,
    consentTimestamp: cookie.consentTimestamp,
    lastConsentTimestamp: cookie.lastConsentTimestamp,
    lang: cookie.languageCode,
    page: location.pathname
  });
}

CookieConsent.run({
  revision: 3,
  onFirstConsent: ({ cookie }) => recordFromCookie('first', cookie),
  onChange: ({ cookie, changedCategories }) =>
    recordFromCookie('change', cookie, changedCategories),
  categories: { /* ... */ },
  language: { /* ... */ }
});

Set revision to something other than the default 0, because 0 disables revision management entirely. With it on, a change to the number invalidates existing consent, the banner is shown again, and onFirstConsent gives you a fresh record under the new revision. That is the mechanism the EDPB’s recommendation to refresh consent at intervals maps onto.

Transport and endpoint

The click that saves consent frequently precedes a reload or navigation, so an ordinary fetch() can be cancelled before it leaves. Use keepalive, or sendBeacon:

function recordConsent(record) {
  const body = JSON.stringify(record);
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/consent-log', new Blob([body], { type: 'application/json' }));
  } else {
    fetch('/consent-log', { method: 'POST', body, keepalive: true,
                            headers: { 'Content-Type': 'application/json' } });
  }
}

Server-side, the requirements are short. Append only; never update or delete a record in the normal flow. Add a server timestamp. Do not set any cookie in the response. Do not proxy the request through a third party, because a consent log that travels via an analytics vendor has recreated the problem it was meant to document. A single table or a newline-delimited file behind a handful of lines of code is enough:

app.post('/consent-log', express.json({ limit: '4kb' }), (req, res) => {
  const record = { ...req.body, serverTime: new Date().toISOString() };
  fs.appendFile('consent.log', JSON.stringify(record) + '\n', () => {});
  res.status(204).end();
});

Give it a retention rule tied to the processing, not to the calendar. While the analytics or advertising the consent covers is still running, keep the record; after it stops, the guidelines say to keep proof only as long as strictly necessary for a legal obligation or for legal claims.

What the log still does not prove

A record of a click is a record of a click. It does not show that the banner rendered as configured, that the decline button was as easy to reach as accept, or that nothing loaded before the choice was made. The ordering problems that break Consent Mode integrations break the evidential value of a consent log in the same way: if analytics fired on page load, a record that the visitor later accepted does not cover the request that already went out.

So the log is one half of the picture. The other half is observing the site from the outside as a first-time visitor and confirming that what happens before the click matches what the record says was consented to afterwards. If you are still weighing whether to run your own library at all, this is one of the costs on the build side of the ledger: not large, but real, and not optional.

Check the other half. Scan your site as a first-time visitor with CookieInspector and see what loads before anyone has clicked anything.

C
About the author
Consent Mode HQ
Editorial team at Consent Mode HQ
Read more by author ↗