Why Cookie Banners Are a Problem

Most users encounter cookie banners daily, but very few actually read them — and for good reason. Many banners are designed to confuse rather than inform. “Accept All” buttons dominate the interface, whilst the option to decline is often hidden behind vague links, multiple screens, or intentionally unclear wording.

This isn’t just bad design — it actively erodes trust. It’s also at odds with the intent of privacy laws like GDPR and PECR, which require clear, affirmative consent for non-essential cookies. A design that discourages users from saying “no” may technically pass muster, but it fails the spirit of the law — and users increasingly know it.

What You’ll Learn

  • What real GDPR-compliant consent looks like
  • How to give users proper control over their data
  • When and how to use localStorage instead of cookies
  • Why “legitimate interest” isn’t a free pass for most tracking
  • How to build a functional, lightweight banner without third-party libraries
  • The legal distinctions between different types of data storage

What “Compliant” Actually Means

Under GDPR, valid consent must be:

  • Freely given — No coercion, manipulation, or negative consequences for refusing
  • Specific — Separate consent for each distinct purpose, not blanket agreement
  • Informed — Clear information about what data is collected and why
  • Unambiguous — A clear positive action, not silence or pre-ticked boxes

If your design is built to nudge users toward agreement — by making “Accept” easier or faster than “Reject” — that’s not valid consent under GDPR. Worse, it damages credibility. Users are increasingly aware of these tactics and are choosing services that respect their preferences without fuss.

The ePrivacy Directive: The Real “Cookie Law”

The ePrivacy Directive (often called the “Cookie Law”) requires consent before storing information on users’ devices, with exceptions only for strictly necessary cookies. This directive works alongside GDPR to form the legal framework for digital privacy in the UK and EU.

The key principle: you need consent for “storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user” — unless that storage is strictly necessary for a service the user has explicitly requested. The UK equivalent is PECR (Privacy and Electronic Communications Regulations), which applies the same principle under UK law post-Brexit.

Designing for Actual Consent

Consent should be a genuine choice — presented clearly, with equal weight given to all options, and with minimal friction applied to rejection as well as acceptance.

Essential Elements of Compliant Consent

Equal prominence for all options — “Accept All” and “Reject All” must have the same visual weight, size, and accessibility. One must not be styled to draw the eye whilst the other is buried.

Granular controls — Users must be able to consent to some cookie categories whilst rejecting others. A single “accept all or nothing” choice does not meet the specificity requirement.

No pre-loading — Non-essential cookies and tracking scripts must not be activated before explicit consent is obtained. This includes loading third-party scripts in the background.

Plain language — Information must use simple, clear language that doesn’t require legal training to understand.

Easy withdrawal — Users must be able to withdraw consent as easily as they gave it. A clearly accessible “Manage Cookies” link achieves this.

What Doesn’t Count as Valid Consent

Pre-ticked boxes — These don’t constitute valid consent under GDPR. Boxes must default to unchecked for non-essential categories.

Scrolling or browsing — Continued site use cannot be interpreted as consent for non-essential cookies.

Cookie walls — Completely blocking website access unless users accept all cookies is prohibited. The ICO has been explicit on this point.

Misleading interfaces — Dark patterns that make rejection difficult, hide options, or require more clicks to decline than to accept are not compliant.

If you have to trick a user into clicking “Accept”, your problem isn’t legal compliance — it’s user respect.

Understanding Cookie Categories

Strictly Necessary Cookies

These cookies are essential for basic website functionality and do not require consent under the ePrivacy Directive / PECR. Examples include session identifiers for logged-in users, shopping basket contents in e-commerce, CSRF security tokens, and load balancer cookies. The key test is whether the site would fail to function as the user requested without them.

Analytics and Performance Cookies

These collect information about how users interact with your site — page views, click paths, time on page, and so on. Third-party analytics tools like Google Analytics process personal data (IP addresses, device identifiers) and send them to external servers. This goes beyond what most users would reasonably expect, and typically requires consent. First-party analytics that aggregate data without sending it to third parties sit in a greyer area, but consent is still the safest approach.

Marketing and Targeting Cookies

These track users across websites to build advertising profiles. They almost always involve third-party processors and cross-site tracking, which places them firmly in the “requires explicit consent” category. There is no credible legitimate interest argument for behavioural advertising cookies under current ICO guidance.

The “Legitimate Interest” Misconception

Many organisations lean on “legitimate interest” as a way to bypass consent requirements. This is frequently misapplied.

Legitimate interest requires a three-part assessment: you must have a genuine legitimate interest, the processing must be necessary to achieve it, and your interests must not be overridden by the individual’s rights and freedoms. This is called the Legitimate Interests Assessment (LIA), and it must be documented.

When Legitimate Interest May Apply

First-party strictly necessary processing for essential functionality — such as fraud prevention or basic security — can potentially rely on legitimate interest. However, even here, the “strictly necessary” exception under PECR is more straightforward and better suited than attempting a legitimate interest argument.

When It Does Not Apply

Marketing cookies, third-party tracking, behavioural analytics, and any processing that involves sharing data with external parties cannot rely on legitimate interest. The ICO has stated clearly that legitimate interest is not an appropriate basis for most cookie-related tracking. For direct marketing, both GDPR and PECR require consent — legitimate interest does not override this.

localStorage vs Cookies: The Legal Reality

A persistent misconception is that using localStorage instead of cookies bypasses consent requirements. It does not.

The ePrivacy Directive / PECR applies to “storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user.” That covers cookies, localStorage, sessionStorage, IndexedDB, and fingerprinting techniques alike. The storage mechanism is irrelevant — the purpose and necessity of the data is what determines whether consent is needed.

When localStorage Requires Consent

If you are storing tracking identifiers, behavioural data, or anything used to profile users — regardless of whether it’s in a cookie or localStorage — you need consent.

When localStorage Does Not Require Consent

If localStorage is used purely for functionality that the user has explicitly requested — such as remembering their preferred language or UI theme purely for display purposes on their own device, without that data being transmitted or processed to identify them — the strictly necessary exception may apply.

Practical Examples

// REQUIRES CONSENT — tracking user behaviour across sessions
localStorage.setItem('user_tracking_id', generateTrackingId());
localStorage.setItem('page_views', JSON.stringify(pageViewData));

// DOES NOT REQUIRE CONSENT — purely local UI preference, not transmitted
// This only holds if this data stays on the device and is not sent to your server
// to build a profile. If it is sent anywhere, reconsider.
localStorage.setItem('ui_preferences', JSON.stringify({
    language: 'en',
    theme: 'dark'
}));

// IMPORTANT NOTE ON SESSION TOKENS:
// Do NOT store authentication session tokens in localStorage.
// localStorage is accessible to any JavaScript running on the page.
// An XSS vulnerability would expose the token to an attacker.
// Session tokens belong in HttpOnly cookies, which JavaScript cannot read.
// This is a security requirement, not just a preference.

How to Build a Compliant Banner

The following implementation uses plain JavaScript with no external dependencies and stores consent preferences locally. It is designed to be a working starting point, not a drop-in solution — you will need to adapt it to your specific third-party services and cookie inventory.

Core Consent Manager

(function () {
    'use strict';

    var CookieConsent = {

        // Default state: only necessary cookies enabled.
        // Non-essential categories default to false — never true.
        // Pre-ticking analytics or marketing would not constitute valid consent.
        defaults: {
            necessary: true,   // Cannot be disabled — always required
            analytics: false,
            marketing: false
        },

        storageKey:   'cookie_preferences',
        timestampKey: 'consent_timestamp',

        /**
         * Retrieve stored preferences, or return defaults if none exist.
         */
        getPreferences: function () {
            try {
                var stored = localStorage.getItem(this.storageKey);
                if (!stored) return this.copyDefaults();
                var parsed = JSON.parse(stored);
                // Always enforce necessary: true regardless of what's stored
                parsed.necessary = true;
                return parsed;
            } catch (e) {
                return this.copyDefaults();
            }
        },

        copyDefaults: function () {
            return {
                necessary: this.defaults.necessary,
                analytics: this.defaults.analytics,
                marketing: this.defaults.marketing
            };
        },

        /**
         * Check whether the user has previously made a consent decision.
         */
        hasConsented: function () {
            return !!localStorage.getItem(this.timestampKey);
        },

        /**
         * Save preferences and apply them immediately.
         * Also logs the consent event server-side for accountability.
         */
        savePreferences: function (preferences, action) {
            // Always enforce necessary: true
            preferences.necessary = true;

            localStorage.setItem(this.storageKey, JSON.stringify(preferences));
            localStorage.setItem(this.timestampKey, new Date().toISOString());

            this.logConsentEvent(preferences, action);
            this.applyPreferences(preferences);
        },

        /**
         * Apply current preferences — load permitted scripts, block the rest.
         * Called both on save and on page load (if consent already given).
         */
        applyPreferences: function (preferences) {
            if (preferences.analytics) {
                this.loadAnalytics();
            }
            if (preferences.marketing) {
                this.loadMarketing();
            }
        },

        /**
         * Dynamically load analytics scripts ONLY after consent is confirmed.
         *
         * IMPORTANT: Google Analytics 4 with Consent Mode requires that you set
         * default denied states BEFORE the gtag.js script loads — not after.
         * If the script loads first, GA4 fires in its default-granted mode and
         * data is already sent before your 'update' call does anything useful.
         *
         * The correct pattern is:
         *   1. Set gtag consent defaults (denied) inline in your , before gtag.js loads.
         *   2. Load gtag.js (it will respect the denied defaults).
         *   3. After consent, call gtag('consent', 'update', { analytics_storage: 'granted' }).
         *
         * If you are not using Consent Mode and simply want to block GA4 entirely
         * until consent is given, load the script dynamically here as shown — but
         * do NOT load gtag.js anywhere else on the page.
         */
        loadAnalytics: function () {
            if (window._analyticsLoaded) return;
            window._analyticsLoaded = true;

            // Option A: Dynamic load (blocks GA entirely until consent)
            var script    = document.createElement('script');
            script.async  = true;
            script.src    = 'https://www.googletagmanager.com/gtag/js?id=YOUR_MEASUREMENT_ID';
            document.head.appendChild(script);

            script.onload = function () {
                window.dataLayer = window.dataLayer || [];
                function gtag() { window.dataLayer.push(arguments); }
                window.gtag = gtag;
                gtag('js', new Date());
                gtag('config', 'YOUR_MEASUREMENT_ID');
            };

            // Option B: If using Consent Mode (set defaults before this runs):
            // if (window.gtag) {
            //     gtag('consent', 'update', { analytics_storage: 'granted' });
            // }
        },

        /**
         * Load marketing scripts (e.g. advertising pixels) after consent.
         * Replace the body of this function with whichever services you use.
         * Each service should be loaded conditionally and only once.
         */
        loadMarketing: function () {
            if (window._marketingLoaded) return;
            window._marketingLoaded = true;

            // Add your marketing script loading here.
            // Example structure — fill in with your actual pixel/service:
            // var script   = document.createElement('script');
            // script.async = true;
            // script.src   = 'https://your-marketing-service.example.com/pixel.js';
            // document.head.appendChild(script);
        },

        /**
         * Log the consent event to your server.
         *
         * GDPR requires you to be able to DEMONSTRATE that consent was validly
         * obtained. There is no fixed retention period specified in the regulation,
         * but you should keep records for as long as you rely on that consent —
         * and long enough to respond to any regulatory enquiry. Many organisations
         * use 3–5 years as a working guideline, informed by their legal team.
         *
         * Note: We send a hashed representation of the IP rather than the raw IP.
         * You may need to retain the raw IP for proof of consent — assess this
         * with your legal adviser based on your specific situation.
         */
        logConsentEvent: function (preferences, action) {
            try {
                fetch('/api/consent-log', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        timestamp:   new Date().toISOString(),
                        action:      action, // 'accept_all', 'reject_all', 'custom'
                        preferences: preferences,
                        userAgent:   navigator.userAgent
                        // IP address is logged server-side from the request —
                        // do not send it from the client where it can be spoofed
                    })
                });
            } catch (e) {
                // Consent logging failure should not block the user experience,
                // but do monitor your server logs for repeated failures.
                console.warn('Consent logging failed:', e);
            }
        }
    };

    // Expose to global scope so banner button handlers can reach it
    window.CookieConsent = CookieConsent;

    /**
     * Check whether stored consent is older than the period you consider stale.
     *
     * GDPR does not mandate a specific consent expiry period. What it requires
     * is that consent remains valid — meaning the purposes and circumstances
     * have not materially changed since it was given. Refreshing consent annually
     * is a widely adopted best practice, but it is guidance, not a hard legal rule.
     * Consult your legal adviser if you are unsure what interval is appropriate.
     */
    function isConsentStale(monthsBeforeRefresh) {
        monthsBeforeRefresh = monthsBeforeRefresh || 12;
        var timestamp = localStorage.getItem(CookieConsent.timestampKey);
        if (!timestamp) return true;

        var consentDate = new Date(timestamp);
        if (isNaN(consentDate.getTime())) return true; // Corrupted timestamp

        var staleDate = new Date();
        staleDate.setMonth(staleDate.getMonth() - monthsBeforeRefresh);

        return consentDate < staleDate;
    }

    // On page load: apply existing consent or show the banner
    document.addEventListener('DOMContentLoaded', function () {
        if (CookieConsent.hasConsented() && !isConsentStale()) {
            CookieConsent.applyPreferences(CookieConsent.getPreferences());
        } else {
            // Either no consent on record, or it's stale — show the banner
            showConsentBanner();
        }
    });

    // ─── Banner show/hide ─────────────────────────────────────────────────────

    function showConsentBanner() {
        var banner = document.getElementById('cookie-banner');
        if (banner) {
            banner.removeAttribute('hidden');
            banner.setAttribute('aria-hidden', 'false');
            // Move focus to the banner for keyboard and screen reader users
            banner.setAttribute('tabindex', '-1');
            banner.focus();
        }
    }

    function hideConsentBanner() {
        var banner = document.getElementById('cookie-banner');
        if (banner) {
            banner.setAttribute('hidden', '');
            banner.setAttribute('aria-hidden', 'true');
        }
    }

    // ─── Button handlers ──────────────────────────────────────────────────────

    function acceptAll() {
        CookieConsent.savePreferences(
            { necessary: true, analytics: true, marketing: true },
            'accept_all'
        );
        hideConsentBanner();
    }

    function rejectAll() {
        CookieConsent.savePreferences(
            { necessary: true, analytics: false, marketing: false },
            'reject_all'
        );
        hideConsentBanner();
    }

    function acceptSelected() {
        var analyticsToggle  = document.getElementById('analytics-toggle');
        var marketingToggle  = document.getElementById('marketing-toggle');
        CookieConsent.savePreferences(
            {
                necessary: true,
                analytics: analyticsToggle ? analyticsToggle.checked : false,
                marketing: marketingToggle ? marketingToggle.checked  : false
            },
            'custom'
        );
        hideConsentBanner();
    }

    // Attach handlers after DOM is ready
    document.addEventListener('DOMContentLoaded', function () {
        var btnAcceptAll  = document.getElementById('btn-accept-all');
        var btnRejectAll  = document.getElementById('btn-reject-all');
        var btnSave       = document.getElementById('btn-save-preferences');
        var btnManage     = document.getElementById('btn-manage-cookies');

        if (btnAcceptAll) btnAcceptAll.addEventListener('click', acceptAll);
        if (btnRejectAll) btnRejectAll.addEventListener('click', rejectAll);
        if (btnSave)      btnSave.addEventListener('click', acceptSelected);

        // "Manage Cookies" link re-opens the banner for consent withdrawal
        if (btnManage)    btnManage.addEventListener('click', function (e) {
            e.preventDefault();
            showConsentBanner();
        });
    });

}());

Banner HTML

The banner uses the hidden attribute rather than style="display:none" so that it is correctly hidden from assistive technology when not shown. The role="dialog" and aria-modal attributes signal to screen readers that this requires a decision before continuing.

<div id="cookie-banner"
     class="cookie-banner"
     role="dialog"
     aria-modal="true"
     aria-labelledby="cookie-banner-title"
     aria-describedby="cookie-banner-desc"
     hidden>

  <div class="cookie-content">
    <h2 id="cookie-banner-title">Cookie Preferences</h2>
    <p id="cookie-banner-desc">
      We use cookies to make our site work. We'd also like to use analytics cookies
      to understand how you use the site and marketing cookies to show relevant content.
      You can choose which categories to allow below.
      <a href="/cookie-policy">Read our Cookie Policy</a>.
    </p>

    <fieldset>
      <legend>Choose which cookies to allow</legend>

      <div class="cookie-category">
        <input type="checkbox" id="necessary-toggle" checked disabled
               aria-describedby="necessary-desc">
        <label for="necessary-toggle"><strong>Necessary</strong></label>
        <p id="necessary-desc">
          Required for the site to function. Cannot be disabled.
          Includes session management and security tokens.
        </p>
      </div>

      <div class="cookie-category">
        <input type="checkbox" id="analytics-toggle"
               aria-describedby="analytics-desc">
        <label for="analytics-toggle"><strong>Analytics</strong></label>
        <p id="analytics-desc">
          Helps us understand how visitors use the site (e.g. page views, traffic sources).
          Data may be processed by third parties such as Google.
        </p>
      </div>

      <div class="cookie-category">
        <input type="checkbox" id="marketing-toggle"
               aria-describedby="marketing-desc">
        <label for="marketing-toggle"><strong>Marketing</strong></label>
        <p id="marketing-desc">
          Used to show relevant advertisements on other websites.
          Involves cross-site tracking by advertising networks.
        </p>
      </div>
    </fieldset>

    <!--
      All three buttons must have equal visual prominence.
      Do not style "Accept All" differently to make it more prominent —
      that constitutes a dark pattern and undermines the validity of consent.
    -->
    <div class="cookie-actions">
      <button type="button" id="btn-reject-all">Reject All</button>
      <button type="button" id="btn-save-preferences">Save My Preferences</button>
      <button type="button" id="btn-accept-all">Accept All</button>
    </div>
  </div>
</div>

<!--
  Place this link in your footer so users can revisit their choices at any time.
  GDPR requires that consent can be withdrawn as easily as it was given.
-->
<a href="#" id="btn-manage-cookies">Manage Cookie Preferences</a>

Key Implementation Principles

  1. No tracking until consent is confirmed — third-party scripts are only loaded after a positive choice is recorded.
  2. Equal visual prominence for all options — Accept, Reject, and Save Preferences buttons must look identical. Styling one differently is a dark pattern.
  3. Granular control — users can consent to analytics without marketing, or neither. The checkboxes default to unchecked for all non-essential categories.
  4. Consent is logged server-side — client-side storage alone is not sufficient to demonstrate consent was obtained. Send the event to your server.
  5. Withdrawal is always accessible — the "Manage Cookie Preferences" link in the footer re-opens the banner at any time.

Handling Google Analytics 4 and Consent Mode Correctly

Google's Consent Mode is commonly misimplemented. The core rule is: default states must be set before the gtag.js script loads. If gtag.js loads without default states being declared, it operates in its default-granted mode and sends data immediately — which defeats the purpose entirely.

There are two valid approaches:

Option A — Block GA4 entirely until consent (simpler, stricter): Do not include the gtag.js script tag in your page at all. Load it dynamically via JavaScript only after the user grants analytics consent, as shown in the loadAnalytics() function above. No data is sent before consent.

Option B — Use Google Consent Mode (more complex, enables modelled data): Place the following snippet inline in your <head>, before gtag.js loads. This sets denied defaults so GA4 respects them from the first request:

<!-- Must appear BEFORE gtag.js loads. Inline — not in an external file. -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { window.dataLayer.push(arguments); }

// Set denied defaults before the gtag.js script loads.
// Without this, GA4 fires in its default-granted state.
gtag('consent', 'default', {
    analytics_storage:  'denied',
    ad_storage:         'denied',
    ad_user_data:       'denied',
    ad_personalization: 'denied',
    wait_for_update:    500  // ms to wait for consent update before firing
});
</script>

<!-- Now load gtag.js — it will respect the denied defaults above -->
<script async src="https://www.googletagmanager.com/gtag/js?id=YOUR_MEASUREMENT_ID"></script>

<script>
gtag('js', new Date());
gtag('config', 'YOUR_MEASUREMENT_ID');
</script>

Then, after the user grants analytics consent, call:

gtag('consent', 'update', {
    analytics_storage: 'granted'
});

Choose one approach and stick to it. Mixing them — such as loading gtag.js unconditionally and also trying to dynamically block it — will result in data being sent before consent regardless of your intentions.

Consent Records: What You Actually Need to Keep

GDPR Article 7 requires that you be able to demonstrate that valid consent was obtained. It does not specify a fixed retention period for consent records. A common working guideline — adopted by many organisations on legal advice — is to retain records for as long as you rely on that consent, plus a reasonable period to respond to regulatory enquiries. Three to five years is frequently cited, but you should confirm this with your own legal adviser as it depends on your specific circumstances.

What a useful consent record should contain:

  • A timestamp of when consent was given
  • What the user was shown at that point (ideally a version reference for your consent notice)
  • What they consented to (the specific categories and their states)
  • A way to identify the consent to a specific interaction — a session ID or hashed IP at minimum

The server-side log endpoint (/api/consent-log) in the implementation above should write this to a database. The IP address is best captured server-side from the incoming request rather than trusting the client to send it accurately.

Testing Your Implementation

Compliance Checklist

  • Banner appears before any non-essential cookies or scripts are loaded — verify in the Network tab with the browser cache cleared
  • Reject All and Accept All buttons have identical visual styling and prominence
  • Non-essential checkboxes default to unchecked
  • Necessary checkbox is checked and cannot be unchecked
  • Selecting individual categories and saving works correctly
  • Preferences persist after closing and reopening the browser
  • Third-party analytics and marketing scripts do not appear in the Network tab when rejected
  • The "Manage Cookie Preferences" footer link re-opens the banner
  • Withdrawing consent (selecting Reject All after a previous Accept) clears analytics/marketing scripts on reload
  • After 12 months (or your chosen period), the banner re-appears
  • The banner is navigable by keyboard alone — tab order is logical, all buttons are reachable
  • The banner is announced correctly by a screen reader (test with NVDA on Windows or VoiceOver on macOS)
  • Consent events appear in your server-side log

How to Test Without Waiting 12 Months

To test the stale consent behaviour without waiting a year, open your browser's developer tools, go to Application → Local Storage, and manually set the consent_timestamp value to a date in the past — for example 2023-01-01T00:00:00.000Z. Reload the page and the banner should reappear.

To test the full flow from scratch, delete both cookie_preferences and consent_timestamp from Local Storage and reload.

Final Thoughts

Cookie consent doesn't need to be a compliance checkbox or a marketing trick. When handled correctly, it is an opportunity to demonstrate that you respect your users' choices and their privacy.

Transparency is the foundation of informed consent. Users should not have to decipher legal terms to understand what they're agreeing to. Design with that principle in mind, and you don't just meet regulations — you build trust.

The regulatory landscape is shifting. UK and European regulators have moved beyond warnings towards meaningful enforcement action against cookie consent violations. The focus is no longer just on having a visible banner — it is on ensuring consent is genuinely freely given, specific, informed, and unambiguous. Websites that use dark patterns to manufacture consent are increasingly the subject of formal complaints and fines.

Respecting user privacy is not just about legal compliance — it is about building sustainable relationships with your audience based on trust. Users who feel respected are more likely to engage meaningfully with your content and services.


This guide provides general technical and contextual information about cookie consent implementation. Privacy law varies by jurisdiction and continues to evolve. Nothing in this article constitutes legal advice. Consult a qualified legal professional familiar with your specific situation and applicable regulations before making compliance decisions.