Privacy, Cookies & Retention — Blazor Blueprint
Login Register

💡 Not legal advice. This page documents the mechanisms the template ships and the obligations they were built to satisfy. Which of them apply to you depends on your jurisdiction and what you do with the data — take your own advice on that.

The Consent Record

Consent is stored in one cookie, cookie-consent, whose value is the dot-separated list of granted categories, always led by necessary:

necessary // everything optional refused
necessary.analytics // analytics granted
necessary.analytics.marketing // accepted all

An absent or unparseable value means never asked, and the banner renders. The lifetime is 183 days, so consent is re-sought roughly every six months, in line with regulator guidance that consent should not be indefinite.

⚠️ Never put a comma in a cookie value — nor a semicolon, backslash, space or quote. RFC 6265 excludes them and the ASP.NET Core parser enforces it: the browser will happily store and send a,b, but Request.Cookies["x"] then returns null. The failure is silent and one-sided — client-side code keeps working perfectly while every server-side gate goes blind. That is why the record above is dot-separated. Test cookie round-trips in a real browser; PowerShell 5.1 silently drops a custom Cookie header and cannot reproduce it.

Where it lives

  • The banner is static SSR — no Blazor circuit — so it renders on the very first paint.
  • All behaviour lives in one script, wwwroot/js/cookie-consent.js: the record format, the category logic, the consent-gated analytics load, and the plugin contract below.
  • Settings are per-organisation, configured at /Platform/CookieConsentConfiguration and /Admin/CookieConsentConfiguration. Like the other deployment settings, an organisation with no domain of its own inherits the operator's.

Nothing Non-Essential Loads Before Consent

This is the part most implementations get wrong. It is not enough to set a "denied" flag on a tag manager that has already loaded — the request itself is the disclosure. So the app publishes only the measurement id as a meta tag, and cookie-consent.js injects the analytics loader after the analytics category is granted. An un-consented visitor never contacts the analytics host at all.

⚠️ Do not add an analytics script tag to any Razor file. It re-breaks this the moment it ships, and nothing will fail visibly — the page will look fine and the consent gate will simply no longer be doing anything.

Category mapping, for Consent Mode v2:

  • analyticsanalytics_storage
  • marketingad_storage + ad_user_data + ad_personalization

Withdrawal actively deletes the analytics cookies. Setting Consent Mode to denied only stops future writes; it leaves whatever is already on the device.

It Applies to All Client-Side Storage

PECR reg 6 and ePrivacy Art 5(3) cover any access to a visitor's device — localStorage, sessionStorage, IndexedDB and fingerprinting, not just cookies. The test is purpose, not mechanism:

  • Exempt — storage strictly necessary for something the visitor asked for: a session id, a "don't show this again" dismissal, a regulator-mandated device id.
  • Needs consent — analytics, advertising, A/B testing, profiling.

If you add anything in the second group, gate it and re-check on change — this is the contract a plugin uses so it doesn't need its own banner:

if (BlazorBlueprint.cookieConsent.has('analytics')) { /* ... */ }

window.addEventListener('bb-consent-changed', function () { /* re-evaluate */ });

Adding such a thing also changes your banner copy: it becomes "cookies and similar technologies", because cookies are no longer the whole story.

Two UI Rules That Are Not Cosmetic

1. The banner demotes itself on policy pages

On the very pages the banner links to — your cookie and privacy policies — a modal banner is demoted to a non-blocking bottom bar. A full-screen overlay covering the document the visitor opened in order to decide makes the consent uninformed, and therefore invalid.

2. Withdrawal stays as easy as consent

GDPR Art. 7(3) requires it to be as easy to withdraw consent as to give it, so a Cookie settings control lives in the footer permanently — not only in the banner that disappears once answered. Two consequences worth knowing:

  • Turning off Features:SiteFooter removes that footer, so the banner grows a standalone "Cookie settings" pill instead. Withdrawal is never stranded.
  • A plugin that suppresses the platform chrome and ships its own footer inherits this obligation — its footer must carry the cookie control and the organisation's legal-policy links, or those pages lose both.

Personal Data Rights

  • Export/Account/Manage/DownloadPersonalData returns every field tagged [PersonalData] as JSON. Encryption is transparent here: the storage layer decrypts on read.
  • Erasure — deletion is soft, with a grace window (BackgroundServices:UserDeletion:GracePeriodDays, default 30). Sign-in is blocked immediately; the user can cancel during the window; a sweep hard-deletes afterwards, removes memberships, and re-evaluates any organisation left without active members or owners.
  • Encryption is a separate concern from export. [Encrypted] governs storage; [PersonalData] governs the download. A field can be either, both, or neither — see Security & Encryption.

Data Retention

"Keep it no longer than necessary" is a legal requirement, so retention is a sweep that runs in the background worker rather than something you remember to do. The windows are bucketed by kind of data, deliberately — one global number would be wrong for at least one bucket:

"BackgroundServices": {
  "DataRetention": {
    "MaxAgeInDays": 90, // operational: notifications, API requests,
                              // email, webhooks, inactive push
    "AuditRetentionInDays": 180, // audit + security trails
    "StaleSessionRetentionInDays": 30, // matches the sliding cookie lifetime
    "RevokedSessionRetentionInDays": 30, // dead sessions — IP + device PII
    "MaxRecordCount": 10000 // row-count backstop
  }
}

⚠️ 0 does not mean "keep forever" everywhere. It disables age-pruning only where the MaxRecordCount backstop still applies — the operational and audit tables. The two session/security tables hold IP and device data with no row cap, so a 0 there falls back to a bounded default rather than growing without limit. To genuinely switch a sweep off, use its Enable* flag, not a zero.

If you write a row per anonymous public request, you owe it a sweep. Anything reachable without a login — a redirect, a public landing page, a scan or click counter — lets an unauthenticated visitor grow a tenant database without bound. The pattern to copy is: prune the per-event detail rows on a schedule, keep the aggregate counter (which is updated atomically and costs nothing to retain).

Private Workspaces

An organisation can be members-only. When it is, anonymous visitors are redirected to sign-in, robots.txt returns Disallow: /, and /sitemap.xml returns 404 — so the workspace is neither reachable nor indexable. Tenant organisations default to private; the platform organisation defaults to public. Toggle it on /Admin/Pages.

Two details that matter operationally. An organisation whose settings row is missing resolves to private — including the platform organisation, which has no structural exemption, so losing that row bounces every anonymous visitor on your own marketing site to a login page. And the anonymous wall is orthogonal to the membership wall: making an organisation public admits anonymous readers to its public pages, it does not admit non-members to the application.

Ready to Build?

Ship a product that respects the rules from day one. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.

🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included

Welcome! How can we help you today?
An unhandled error has occurred. Reload