Privacy and Consent in Analytics: The KVKK Cookie Guide, Consent Mode and Cookieless Alternatives
You do not load the analytics tag before consent, and when the consent rate changes, GA4's number drops without any change in traffic; you tell them apart with a source consent does not affect. We cover what the KVKK cookie guide means for measurement, ITrackingConsentFeature in ASP.NET Core, consent-gated loading in Angular, basic vs advanced Consent Mode, the 2026 change and cookieless alternatives. Not legal advice.
Version note: The code in this post was run with .NET 10 (ASP.NET Core 10), Angular 22.2 (TypeScript 6.0) and the Chromium of Playwright 1.65. This post is based on the official documentation: the KVKK Guide on Cookie Practices (KVKK Publications No. 69, July 2025), Google's Consent Mode and Google Analytics docs and Microsoft Learn, as of 15 November 2026. This post is not legal advice; which legal basis applies to which cookie is for your legal counsel to decide.
Guess First
On 26 October the cookie banner changed on the blog of Bookstore, the online bookstore used as the shared example across this site's series: the old one had a large, colorful "Accept" button, the new one shows "Accept" and "Reject" at the same size and in the same color. A week later organic search sessions in GA4 seem to have dropped by about a third, while Search Console clicks are where they were. Selin asks: "Did something break in SEO?" Did traffic drop, or did measurement? Which data would you use to tell them apart?
The KVKK Cookie Guide: What a Developer Needs to Know
The current edition of KVKK's Guide on Cookie Practices is dated July 2025. How the guide classifies cookies, the Criterion A and Criterion B distinction and offering "accept" and "reject" equally on the banner were covered in detail in the Data Privacy and KVKK for Developers post "Privacy Notices, Explicit Consent and Cookies"; that post also has a table that records consent and a banner in Angular. Here we look at two points that matter for measurement.
First, cookies that require explicit consent are not placed before consent is given, and consent is given through an active action; merely entering the site is not consent. On the analytics side the technical meaning is clear: the tag does not load before the visitor allows it. Second, section 5.9 of the guide says that first-party analytics cookies can, under certain conditions, fall under Criterion B, meaning without explicit consent: the purpose has to be limited to running the site and measuring its audience, only anonymous statistics may be produced, and there must be no tracking across sites; the guide also lists masking the IP, keeping the cookie lifetime reasonable and not passing the data to third parties as possible measures. GA4, a third-party tool, does not fit that description easily, so Bookstore ties GA4 to consent. That is an engineering choice; on your site, your legal team decides. For readers in the European Union, GDPR and the cookie rules need their own assessment.
Consent in ASP.NET Core: ITrackingConsentFeature
ASP.NET Core's consent support has two parts. When CookiePolicyOptions.CheckConsentNeeded returns true, the UseCookiePolicy middleware does not send cookies that are not marked essential (not IsEssential) to the browser without consent. ITrackingConsentFeature gives the request's consent state (CanTrack) and writes and deletes the .AspNet.Consent cookie through GrantConsent/WithdrawConsent.
builder.Services.AddRazorPages();
builder.Services.Configure<CookiePolicyOptions>(options =>
{
// non-essential cookies (and our analytics) wait for consent
options.CheckConsentNeeded = _ => true;
options.MinimumSameSitePolicy = SameSiteMode.Lax;
});
builder.Services.AddScoped<CspNonce>();
builder.Services.Configure<AnalyticsOptions>(builder.Configuration.GetSection(AnalyticsOptions.SectionName));
builder.Services.AddHttpClient<Ga4MeasurementProtocolClient>(http => http.Timeout = TimeSpan.FromSeconds(2));
WebApplication app = builder.Build();
app.UseContentSecurityPolicy();
app.UseStaticFiles();
app.UseCookiePolicy();
app.MapRazorPages();
app.MapAnalyticsConsent();
using Microsoft.AspNetCore.Http.Features;
namespace Bookstore.Analytics.Web.Analytics;
public static class ConsentEndpoints
{
public static IEndpointRouteBuilder MapAnalyticsConsent(this IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/consent/analytics");
// writes the .AspNet.Consent cookie
group.MapPost("/grant", (HttpContext context) =>
{
context.Features.Get<ITrackingConsentFeature>()!.GrantConsent();
return Results.NoContent();
});
group.MapPost("/withdraw", (HttpContext context) =>
{
context.Features.Get<ITrackingConsentFeature>()!.WithdrawConsent();
// GA4 sets _ga and _ga_<container-id>; ours is bs_ga_cid
foreach (string name in context.Request.Cookies.Keys
.Where(n => n == "_ga" || n.StartsWith("_ga_", StringComparison.Ordinal) || n == ClientIdCookie.Name))
{
context.Response.Cookies.Delete(name);
}
return Results.NoContent();
});
return app;
}
public static bool CanTrack(this HttpContext context) =>
context.Features.Get<ITrackingConsentFeature>()?.CanTrack ?? false;
}
On the Razor side the Google tag is now rendered only when CanTrack is true, and its first command is the consent state:
@using Microsoft.Extensions.Options
@using Bookstore.Analytics.Web.Analytics
@using Bookstore.Analytics.Web.Security
@inject IOptions<AnalyticsOptions> Analytics
@inject CspNonce Nonce
@if (Analytics.Value.MeasurementId is { Length: > 0 } id && Context.CanTrack())
{
<script async src="https://www.googletagmanager.com/gtag/js?id=@id"></script>
<script nonce="@Nonce.Value">
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
'analytics_storage': 'granted',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied'
});
gtag('js', new Date());
gtag('config', '@id');
</script>
}
The PDF endpoint also sends the Measurement Protocol event only when context.CanTrack() is true, even if the bs_ga_cid cookie from the previous post is still in the browser. In a local run the flow looks like this:
$ curl -s http://127.0.0.1:52704/ | grep -c googletagmanager
0
$ curl -si -X POST http://127.0.0.1:52704/consent/analytics/grant | grep -i set-cookie
Set-Cookie: .AspNet.Consent=yes; expires=Mon, 15 Nov 2027 10:19:33 GMT; path=/; samesite=lax
$ curl -s -H "Cookie: .AspNet.Consent=yes" http://127.0.0.1:52704/ | grep -c googletagmanager
1
$ curl -si -X POST -H "Cookie: .AspNet.Consent=yes; _ga=GA1.1.1.1" http://127.0.0.1:52704/consent/analytics/withdraw | grep -i set-cookie
Set-Cookie: .AspNet.Consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax
Set-Cookie: _ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax
.AspNet.Consent is valid for a year and is not HttpOnly; that suits us, because the Angular app can read the same decision. On withdrawal the consent cookie and GA4's _ga and _ga_<container-id> cookies are deleted. One limit: ASP.NET Core's consent is a single yes or no; with several categories such as analytics, advertising and personalization, you have to record the decision yourself, with its version and time. The tests check four cases: without consent there is no tag and no Set-Cookie on the page, the PDF downloads but nothing goes to GA4, grant writes the cookie, withdraw deletes the cookies.
$ dotnet test --filter "FullyQualifiedName~ConsentTests"
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 420 ms - Bookstore.Analytics.Tests.dll (net10.0)
Consent-Gated Loading in Angular
In bookstore-web the decision is a signal. ConsentService looks at the .AspNet.Consent cookie at startup; it also remembers a "no" in localStorage so the banner does not come back on every visit:
import { HttpClient } from '@angular/common/http';
import { DOCUMENT, Injectable, inject, signal } from '@angular/core';
export type ConsentState = 'unknown' | 'granted' | 'denied';
@Injectable({ providedIn: 'root' })
export class ConsentService {
private readonly http = inject(HttpClient);
private readonly doc = inject(DOCUMENT);
private static readonly DeniedKey = 'analytics-consent';
readonly analytics = signal<ConsentState>(this.read());
grant(): void {
// the server writes .AspNet.Consent; Razor pages and the PDF endpoint read it
this.http.post('/consent/analytics/grant', null).subscribe(() => {
this.doc.defaultView?.localStorage.removeItem(ConsentService.DeniedKey);
this.analytics.set('granted');
});
}
deny(): void {
// remember the "no" too, so the banner is not shown on every visit
this.doc.defaultView?.localStorage.setItem(ConsentService.DeniedKey, 'denied');
this.http.post('/consent/analytics/withdraw', null).subscribe(() => this.analytics.set('denied'));
}
reopen(): void {
this.analytics.set('unknown');
}
private read(): ConsentState {
if (this.doc.cookie.split('; ').includes('.AspNet.Consent=yes')) {
return 'granted';
}
return this.doc.defaultView?.localStorage?.getItem(ConsentService.DeniedKey) === 'denied' ? 'denied' : 'unknown';
}
}
export class App {
constructor() {
const analytics = inject(AnalyticsService);
const consent = inject(ConsentService);
effect(() => {
if (consent.analytics() === 'granted') {
analytics.start();
} else {
analytics.stop();
}
});
}
}
The tag now loads only in the granted state, and its first command while loading is gtag('consent', 'default', ...): analytics allowed, every advertising type off. If consent is given on an already open page, AnalyticsService counts that page once; if it is withdrawn, it sends analytics_storage: 'denied' to the tag and sends no more events from that page. I tried three visits in Playwright's Chromium; requests to Google are blocked, and the script only counts whether gtag.js was requested:
$ node consent-check.mjs http://127.0.0.1:52714
## reject: gtag.js requested 0 time(s)
## accept: gtag.js requested 1 time(s)
consent default {"analytics_storage":"granted","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied"}
config G-TEST123 {"send_page_view":false}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"","page_title":"search-console-api - Bookstore Blog"}
## returning visitor with consent cookie: gtag.js requested 1 time(s)
consent default {"analytics_storage":"granted","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied"}
config G-TEST123 {"send_page_view":false}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"","page_title":"search-console-api - Bookstore Blog"}
For the visitor who rejected, gtag.js was never requested and dataLayer stayed empty. In the first attempt the banner never appeared; the console had an NG0200 error: I had injected the Router into AnalyticsService, and since the Router depends on AnalyticsService through TitleStrategy, that created a circular dependency. Having the service learn "has the first navigation finished?" from TitleStrategy instead of the Router fixed it.
Google Consent Mode: Basic and Advanced
Consent Mode is not a banner; it is an API that tells Google tags what the visitor decided. Its types are ad_storage, ad_user_data, ad_personalization, analytics_storage and functionality_storage, personalization_storage, security_storage. The default command must come before the config and event commands on every page.
Google describes two implementations. In basic, tags do not load at all until the visitor interacts with the banner; on rejection not even the consent state goes to Google. In advanced, tags load with the page, the default state is denied, and on rejection cookieless measurement pings are sent. For measurement the difference is large: GA4's behavioral modeling, which estimates the behavior of users who declined from those who accepted, works only with the advanced implementation. It also needs at least 1,000 events per day with analytics_storage='denied' for at least 7 days, and at least 1,000 daily users who granted consent on at least 7 of the previous 28 days; the reporting identity has to be "Blended". Modeled data applies to user and session metrics, not to event counts and not to the BigQuery export. A small site such as the Bookstore blog never reaches these thresholds; with the basic implementation, the number GA4 shows is the number of visitors who consented.
2026 brought a change as well. According to the "Updates to Google Analytics Data Controls" page in Google Analytics Help, from 15 June 2026 Consent Mode becomes the single control for the Google Ads cookies and IDs collected by the Google Analytics tag; the Google Signals setting then only controls linking Google Analytics data to signed-in user information for behavioral reporting. The page also says ads personalization will be controlled only by ad_personalization and that IP addresses will be encrypted and passed to the linked Ads account, but as of 15 November 2026 it gives no dates for these two. In Bookstore, which does not use Ads, every advertising type is denied in every case.
Refused Consent Is Not a Traffic Drop
Back to the opening question. A banner change changes the consent rate, not the traffic; GA4 sees only the visitors who consent, so the chart drops. The way to tell them apart is to compare with a source consent does not affect: the Google landings counted by the log classifier from the first post. The code below totals both per week and gives the ratio:
using System.Globalization;
namespace Bookstore.Analytics.Consent;
public sealed record CoverageDay(DateOnly Date, int LogGoogleLandings, int Ga4OrganicSessions);
public sealed record CoverageWeek(DateOnly From, int LogGoogleLandings, int Ga4OrganicSessions)
{
// Sessions and landing requests are different units: watch how the ratio moves, not its level.
public double Coverage => LogGoogleLandings == 0 ? 0 : (double)Ga4OrganicSessions / LogGoogleLandings;
}
public static class CoverageEstimator
{
public static IEnumerable<CoverageDay> ReadCsv(string path) =>
File.ReadLines(path).Skip(1).Where(l => l.Length > 0).Select(l => l.Split(',')).Select(c => new CoverageDay(
DateOnly.Parse(c[0], CultureInfo.InvariantCulture),
int.Parse(c[1], CultureInfo.InvariantCulture),
int.Parse(c[2], CultureInfo.InvariantCulture)));
// Whole weeks only, starting from the first day, so weekdays stay comparable.
public static List<CoverageWeek> Weekly(IEnumerable<CoverageDay> days)
{
List<CoverageDay> ordered = days.OrderBy(d => d.Date).ToList();
return ordered
.Select((day, index) => (day, week: index / 7))
.GroupBy(x => x.week)
.Where(g => g.Count() == 7)
.Select(g => new CoverageWeek(
g.First().day.Date,
g.Sum(x => x.day.LogGoogleLandings),
g.Sum(x => x.day.Ga4OrganicSessions)))
.ToList();
}
}
data/coverage-daily.csv is a synthetic 28-day dataset: it was made to show the effect of the banner change; it is not data from a real site.
$ dotnet run --project src/Bookstore.Analytics.Cli -- coverage data/coverage-daily.csv
week from log landings ga4 sessions coverage
2026-10-12 1865 1157 62%
2026-10-19 1939 1202 62%
2026-10-26 1810 742 41%
2026-11-02 1903 781 41%
ga4 sessions -32%, log landings +2%
GA4 sessions dropped 32 percent while Google landings in the log rose 2 percent; the ratio breaks from 62 to 41 exactly in the banner week. Measurement coverage changed, not traffic. A session and a landing request are not the same unit, so we look at where the ratio breaks, not at its level.
Cookieless Alternatives
If you want a number that does not depend on the consent rate, cookieless tools are an option. The table below relies on each tool's own documentation; statements such as "no banner needed" are the vendor's claim, not a legal assessment.
| Tool | How it counts | What you lose | Note |
|---|---|---|---|
| Plausible | No cookies; a daily identifier from a hash of a daily salt, the domain, the IP and the user agent; the salt is deleted every 24 hours | Unique visitors across days, detailed user journeys | The vendor says no banner is needed; raw IPs are not stored |
| Umami | The web tracker uses no cookies | Limited returning-visitor detection | Open source, can be self-hosted; it says compliance depends on your configuration |
| Matomo, cookieless mode | _paq.push(['disableCookies']) turns analytics cookies off | Returning-visitor numbers become less accurate | Self-hosted; some essential cookies can still be set |
| Cloudflare Web Analytics | No cookies or localStorage; counts visits, not unique people | Unique visitors, detailed event analysis | The JavaScript beacon still runs in the browser |
| Server log only | Every request; Google landings through the Referer | On-page behavior; no JavaScript measurement | No script; retention of IPs and user agents still needs thought |
In terms of accuracy none of them gives the detail GA4 gives for consenting users; in return they give a steadier trend that does not depend on the consent rate. Using two tools on one site is also possible: a cookieless tool for the trend, GA4 for the detailed behavior of the consenting minority.
Production Notes
Record banner changes. When the text, button layout or default value changes, note the date and what changed; a Search Console annotation or a change file is enough. Otherwise you will not be able to explain a break months later.
Track the consent rate separately. Count on the server, without personal data, how often the banner was shown and how many decisions were accept or reject; interpreting a drop in GA4 depends on that number.
Put the default command first. Even if you load the tag through a tag manager, the consent default must run before the tag; otherwise the first hit goes out without a consent state.
Make withdrawal easy. After the banner closes, leave a small "Cookie settings" button; the guide expects consent to be withdrawable and the panel to be easy to reach for that.
Common Pitfalls
Assuming "an anonymized IP is enough". Masking the IP is only one of the measures the KVKK guide lists for first-party analytics; the cookie still recognizes a device, and if the tool is a third party, the data still leaves.
A banner whose default is "accept". Pre-checked boxes, "by continuing you accept" text, or a faint reject link next to a large colorful accept button do not fit the free and active consent the guide looks for.
Taking a consent-driven drop for a traffic drop. Do not say "traffic fell" without comparing GA4 with a source consent does not affect, such as Search Console or the log.
Expecting modeled data in basic mode. Behavioral modeling needs the advanced implementation and high traffic thresholds; on a small site GA4 shows only the visitors who consented.
A Small Experiment
★☆☆ Add a fourth visit to consent-check.mjs: the visitor accepts first, then rejects from "Cookie settings". Which calls land in dataLayer after the rejection?
★★☆ Add an endpoint on the server that counts banner decisions: only the date and the decision (accept or reject), without IP or user agent. Write a test that returns the weekly acceptance rate.
★★★ Add a break detector to CoverageEstimator: when the weekly ratio falls below the lowest value of the previous four weeks, it warns "possibly a consent or tag change". Test it with synthetic data.
We have covered the privacy side of measurement. In the next post we move to Core Web Vitals: what Google actually says about their SEO effect, CrUX field data and building your own RUM with web-vitals.