Building a KVKK- and GDPR-friendly cookie banner with Consent Mode v2
A default denial that runs before any Google tag, versioned stored preferences and revocable consent: a step-by-step cookie banner.
Adding Google Analytics or AdSense to a site looks like a job of a few lines. The real effort is making those tags behave according to the visitor's consent: no cookies before consent, applying a change of mind immediately, and doing all of this on every page load without delay. Google's Consent Mode v2 is the standard way to communicate that behaviour to the tags; the cookie banner is the interface where we collect the visitor's decision.
In this article we walk through the setup we use on this site: a default denial that runs at the very top of the page, a stored preference with a version number, a reject button that is as easy as accept, and consent that can be withdrawn at any moment. One caveat first: what follows is a technical implementation example, not legal advice. Obligations under Turkey's KVKK and the EU's GDPR depend on what a site collects and who it shares data with, so your texts need to be reviewed together with a lawyer.
The four signals of Consent Mode v2
Consent Mode uses a set of keys that tell Google tags which uses the visitor has allowed. Version 2 added two new signals on the advertising side, so today we work with four main signals:
analytics_storage: whether cookies and similar storage for analytics (for example Google Analytics'_gacookies) are allowed.ad_storage: whether advertising-related cookies may be written and read.ad_user_data: consent to sending user data to Google for advertising purposes.ad_personalization: consent to personalised advertising, such as remarketing.
Each signal takes the value 'granted' or 'denied'. Our banner offers the visitor two categories, "Analytics" and "Advertising". The Analytics box controls analytics_storage, and the Advertising box controls the three advertising signals together. Offering two understandable choices instead of four technical terms also helps make sure consent is actually informed.
Default denial, before anything else
Ordering is the most important part of this setup. Before any Google tag loads, the default value of every signal must be declared as 'denied'. Otherwise a tag may, for a brief moment before the default arrives, behave as if consent had been given. That is why we print the default not in a separate file but as an inline script inside <head>, ahead of all Google code:
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
gtag('set', 'ads_data_redaction', true);
Two settings here deserve an explanation. wait_for_update: 500 tells the tags "an update may be coming, wait up to 500 milliseconds before sending anything". Because we apply any stored preference in the very next script, this window is usually not needed at all, but it provides a safety margin if the ordering ever changes unexpectedly. ads_data_redaction asks for information such as click identifiers to be removed from ad requests while ad_storage is denied.
Using an inline script raises a Content Security Policy question: how do you allow it without enabling 'unsafe-inline'? We answer that in detail in our CSP article, where the SHA-256 hash of the script is added to the header.
Applying the stored preference immediately
Right after the default, a small file loaded synchronously (without async or defer) reads the decision the visitor made earlier and applies it with gtag('consent', 'update', …). Only after that does the measurement tag itself get added to the page. We keep the preference as a small JSON object in the browser's localStorage:
var KEY = 'cerez_tercihi';
var LIFETIME_MS = 365 * 24 * 60 * 60 * 1000; // a preference is valid for 12 months
var VERSION = '2026-09-29'; // version of the cookie policy
function readPreference() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
if (!v || v.s !== VERSION || typeof v.t !== 'number') return null;
if (Date.now() - v.t > LIFETIME_MS) return null;
return v; // {s: version, a: analytics, r: ads, t: time}
} catch (e) { return null; }
}
function permissions(p) {
var a = p && p.a ? 'granted' : 'denied';
var r = p && p.r ? 'granted' : 'denied';
return { analytics_storage: a, ad_storage: r, ad_user_data: r, ad_personalization: r };
}
var stored = readPreference();
if (stored) gtag('consent', 'update', permissions(stored));
Keeping the preference in localStorage rather than in a cookie means it is not sent to the server with every HTTP request; it stays in the browser. Because localStorage can throw in private windows or in browsers with storage disabled, reads and writes are wrapped in try blocks. If storage is unavailable, the preference cannot be read and the banner is shown again, which is always a safer failure than measuring without consent.
Preference version and a twelve-month lifetime
Two fields in the stored object matter in particular. The first is s, the version of the cookie policy. When the policy text changes in substance, for example when advertising cookies are added to the site, we increase the version constant on the server. The banner and the tag file read that version from the page; if the stored preference's version does not match the current one, the preference is ignored and the banner asks the visitor again. Consent given to an old text is therefore not treated as valid for a new use with a wider scope.
The second is t, the moment the preference was saved. Preferences older than twelve months are also ignored and the question is asked again. That period does not come from a specific legal provision; it reflects our wish to remind visitors of their choice at reasonable intervals. Set the period for your own site together with your lawyer.
Rejecting must be as easy as accepting
For consent to be freely given, the way to refuse must not be hidden. In our banner the "Reject all" and "Accept all" buttons have the same size and the same visual weight and sit side by side. For visitors who want a detailed choice, a "Preferences" button opens a panel, and the Analytics and Advertising boxes in that panel come unticked. The strictly necessary row is ticked and cannot be changed, because features such as the language preference or the message shown after a form submission depend on it, and we say so plainly.
Saving is handled by a single function that stores the choice and reports it to Google:
function save(analytics, ads) {
var previous = readPreference();
var p = { s: VERSION, a: !!analytics, r: !!ads, t: Date.now() };
try { localStorage.setItem(KEY, JSON.stringify(p)); } catch (e) {}
gtag('consent', 'update', permissions(p));
if (!p.a && (!previous || previous.a)) deleteAnalyticsCookies();
closeBanner();
}
The banner HTML is rendered on the server and starts out hidden. The script only manages behaviour: if there is no valid preference it shows the banner and moves focus to the first visible button for keyboard users. We use role="dialog" together with a linked title and description so screen readers announce the banner as a dialog.
Withdrawing consent
Consent should be as easy to withdraw as it was to give. The footer of every page on the site has a "Cookie preferences" link that reopens the banner with the preferences panel expanded. When a visitor withdraws analytics consent, the Consent Mode update is sent immediately, and any Google Analytics cookies left in the browser (_ga, _ga_*, _gid, _gat) are deleted. Since we do not know at which domain level a cookie may have been written, deletion is repeated for every parent level of the host name:
function deleteAnalyticsCookies() {
var parts = location.hostname.split('.');
var domains = [location.hostname, ''];
for (var i = 0; i < parts.length - 1; i++) domains.push('.' + parts.slice(i).join('.'));
document.cookie.split(';').forEach(function (c) {
var name = c.split('=')[0].trim();
if (name === '_ga' || name.indexOf('_ga_') === 0 || name === '_gid' || name === '_gat') {
domains.forEach(function (d) {
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/' + (d ? '; domain=' + d : '');
});
}
});
}
Basic versus advanced implementation
Google describes two ways of implementing Consent Mode. In the basic implementation, Google tags are not loaded at all until the visitor consents; without consent, no request goes to Google. In the advanced implementation, tags load on page view with the default denied state; while permission is denied they do not write cookies, but they may send signals without identifiers, which Google's documentation calls "cookieless pings". Google uses those signals to estimate gaps in measurement through modelling.
Our setup, which loads the tags with a default denial, corresponds to the advanced form, and our privacy text says so explicitly: without consent no analytics cookies are written, only cookieless signals may be sent. Which form suits you depends on your measurement needs and on your lawyer's view of cookieless signals sent without consent. If you choose the basic implementation, the change is simple: make the code that adds the measurement tag to the page conditional on a stored preference that grants permission.
Points to watch under KVKK and GDPR
Besides the technical setup, the texts have to be consistent. These are the points we pay attention to:
- The short text in the cookie banner and the privacy and cookie policy page must say the same thing; if the banner says "analytics only" while the policy describes advertising cookies, that is a contradiction.
- The policy should list every cookie's name, purpose, type (first-party or third-party) and duration in a table.
- Strictly necessary cookies must really be necessary. Putting an analytics cookie under the "necessary" heading makes the consent mechanism meaningless.
- If ads are shown to visitors from the European Economic Area, the United Kingdom and Switzerland, Google requires a consent management platform certified by Google. Our banner hides itself when Google's own consent message appears on the page, so the visitor is not asked the same question twice.
These items are a checklist, not a legal opinion. KVKK has its own rules on topics such as transferring personal data abroad, and the final version of your texts should always be read by a lawyer. On this site the privacy text itself is published in Turkish only.
Conclusion
A good cookie banner is less about how it looks and more about ordering: the default denial first, the stored preference right after it, the measurement tag last. Storing the preference with a version and a lifetime makes asking again automatic when the policy changes or time passes. Equal-weight accept and reject buttons, unticked boxes and a withdrawal link reachable from every page make sure consent really is the visitor's decision. Keeping each of these pieces in small, readable functions also makes it easier to show, during an audit, what was done and why.
Building something similar?
Describe what you need in a few sentences; if there is a good fit, we will work out the details together.
Write to us