Skip to content

Privacy, Cookies & Retention

Consent, personal-data rights, and how long data actually lives.

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.

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 — including one that does not contain necessary — means never asked, and the banner renders. The lifetime is 183 days, so consent is re-sought roughly every six months rather than kept indefinitely. The cookie is written SameSite=Lax, scoped to Authentication:Cookie:Domain on the platform host and its subdomains, and host-only on a customer hostname — so each organisation website is its own consent boundary, and consent given to the application never carries over to a site, or the reverse. That boundary holds because an organisation cannot connect a hostname at or beneath Authentication:Cookie:Domain: such a name would be sent the application’s cookies, the consent cookie included.

A companion cookie, cookie-consent-scope, records which cookie policy the visitor was shown when they chose: the platform’s on an application host, or one site’s own (site.{provider}.{target}) on a customer hostname. A choice made under one policy does not count under another’s — two sites of one organisation are two policies — so the banner asks again, and the consent check answers “no” until it has.

Never put a comma in a cookie value — nor a semicolon, backslash, space or double quote. RFC 6265 excludes them, and ASP.NET Core’s parser enforces it: the browser happily stores and sends a,b, but Request.Cookies["x"] returns null. Client-side code keeps working while every server-side check silently sees no cookie — which is why the record above is dot-separated. Test cookie round-trips in a real browser.

Where it lives

  • The banner (CookieConsentBanner.razor) is static SSR — no Blazor circuit — so it renders on the 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.
  • Two regimes, resolved per request (ConsentRegimeResolver, read by both the document head and the banner so they cannot disagree):
    • Application hosts (the platform host and organisation subdomains) show the operator’s banner, configured once at /Platform/CookieConsent. It is a deployment setting: organisations have no banner of their own and no /Admin page for one.
    • A customer hostname shows the banner of the site it serves, supplied by the site’s plugin as SiteDocumentHead.CookieBanner in the same CookieConsentSettings shape, with policy links to the site’s own pages. It shows when that site’s settings say Enabled and never otherwise — there is no automatic display, so a site that loads analytics or embeds with its banner off is its owner’s decision. A site with no banner gets none.
  • Your platform privacy policy should disclose application analytics. On an application page the full path and page title are sent (see below); say so.
  • The banner’s privacy and cookie policy links default to /legal/privacy-policy and /legal/cookie-policy — see Legal policies.

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 analytics measurement id, as a <meta name="bb-analytics-id"> tag, and cookie-consent.js injects the Google Analytics loader only once the analytics category is granted. A visitor who has not consented never contacts the analytics host at all.

Which property. Every application page measures into the deployment’s one property, set at Platform → Platform Settings (/Platform/Settings; a single platform-database row). There is no deployment variable for it: a new deployment starts with no id, and analytics stays off until a platform admin enters one. With the box blank no measurement id is published and the analytics loader never runs, so even a visitor who grants the analytics category sends nothing. There is no per-organisation analytics id on the application. A page on a customer hostname measures into that site’s own property, supplied by the site’s plugin, and never into yours. A preview link sends nothing at all, shows no cookie banner, and cannot read or change the visitor’s consent for the application: it is served on the application’s origin, so cookie-consent.js treats the page as read-only there (has() answers no).

What a page view carries. gtag’s automatic page view is switched off (send_page_view: false), because it sends location.href verbatim. cookie-consent.js sends its own page_view instead — on load and on every in-app navigation — with the origin, the page title, a redacted referrer and a redacted path. The grant is re-read at the moment each page view is sent, so withdrawing consent stops measurement on the very next navigation, and the first page view waits until the document has loaded so it carries the real title. The referrer gets the same treatment as the location: a same-origin referrer is sent as its redacted path, a cross-origin one as its origin only, and the in-app referrer is the previous redacted location. For the location, the query string and fragment are always dropped, and a path segment that carries a secret (a share token, a portal access code) is replaced by its template name, so /preview/abc123/about is sent as /preview/{token}/about. The templates are the host’s own (/preview/{token}) plus whatever plugins declare through IAnalyticsRedactionProvider, published to the page as <meta name="bb-analytics-redact">; on the shared platform host a template also matches beneath /org/{name}/.

Turn off history-based page views in Google Analytics

In the GA data stream’s enhanced measurement settings, turn off Page changes based on browser history events. Left on, GA sends a second, unredacted page view on every in-app navigation, alongside the redacted one.

Do not add an analytics <script> tag to any Razor file. Nothing fails visibly when you do — the page looks fine and the consent gate simply stops doing anything.

Category mapping, for Google Consent Mode v2:

  • analytics → analytics_storage
  • marketing → ad_storage + ad_user_data + ad_personalization

Withdrawal actively deletes the analytics cookies (_ga*, _gid, _gat*), and withdrawing marketing deletes the ad cookies gtag writes under ad_storage (_gcl_*, _gac_*). A plugin that stores anything of its own under either category deletes it when bb-consent-changed reports the withdrawal; the script cannot know its names. 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, session replay, profiling.

If you add anything in the second group, gate it and re-check when consent changes. This is the contract a plugin uses, so it needs no banner of its own:

if (BlazorBlueprint.cookieConsent.has('analytics')) { /* ... */ }
document.addEventListener('bb-consent-changed', function () { /* re-evaluate */ });

The event fires on every change and bubbles, so a listener on window works too. Adding such storage also changes your banner copy: it should say “cookies and similar technologies”, because cookies are no longer the whole story.

Two UI rules that are not cosmetic

The banner demotes itself on policy pages

On the privacy and cookie policy pages the banner links to, a modal banner is shown as a non-blocking bottom banner instead. A full-screen overlay covering the document the visitor opened in order to decide makes the consent uninformed.

GDPR Art. 7(3) requires withdrawing consent to be as easy as giving it, so a Cookie settings control lives permanently in the site footer, not only in the banner that disappears once answered.

  • Turning off the SiteFooter switch (Platform Settings) removes that footer, so the banner renders a standalone “Cookie settings” button 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 deployment’s legal-policy links. See Plugins.

Public images are stripped of their metadata

A file uploaded as public — an avatar, a site logo, a social preview image, a favicon — is served anonymously and cacheably at /api/files/public/{id}. A photo taken on a phone carries the GPS coordinates it was taken at, the capture time, and often the camera’s serial number; published as-is, so does the URL.

So the bytes are rewritten before anything is stored. Public images must be PNG, JPEG, GIF, WebP, BMP or ICO, and every metadata block is dropped: EXIF, XMP, IPTC and vendor blocks on JPEG; everything outside a short allow-list of rendering chunks on PNG; everything outside an allow-list of image, alpha, colour-profile and animation chunks on WebP (clearing the header flags that referenced what was dropped); comment extensions and every application extension except the animation-loop ones on GIF (an XMP packet travels in one); and, on ICO, the same PNG allow-list applied to each embedded PNG image. BMP has no metadata container and is stored unchanged. A file whose structure does not parse — a truncated chunk, a size field claiming more bytes than the file holds — is refused rather than stored.

  • Lossless. It walks the file’s container rather than re-encoding it: pixel data is copied byte for byte, a JPEG is not recompressed, and an animation still animates.
  • Rendering is preserved. ICC colour profiles, JFIF density and the Adobe colour-transform marker are kept, because dropping them changes how the image looks rather than what it discloses.
  • A malformed image is refused, not stored as-is — storing the original would defeat the control on exactly the input strange enough to break it.
  • No native imaging dependency is added to your deployment.

What this is not. It is not a re-encode and not a malware scan, so it does not neutralise a payload hidden in the pixel data; safe serving of public images still rests on content-type sniffing, the attachment disposition and the nosniff header. Member-visibility files are not stripped — they are served only to signed-in members of the owning organisation, and silently rewriting a member’s uploaded documents would be a problem of its own.

Personal data rights

  • Export — /Account/Manage/DownloadPersonalData returns PersonalData.json: the user’s [PersonalData] fields (decrypted), their email, user name and phone number, each linked external login, their authenticator key, and a count of remaining recovery codes (only hashes of the codes are stored, so the codes themselves cannot be exported). Each download writes an audit row. It covers the user’s account record only — not content they created inside an organisation. See Security → Personal data export.
  • Erasure — users request deletion at /Account/Manage/DeletePersonalData; a platform admin can also request it from /Platform/Users. Deletion is soft, with a grace window (BackgroundServices:UserDeletion:GracePeriodDays, default 30, minimum 1). Sign-in is blocked immediately and the user can restore the account from /Account/DeletionStatus during the window. The last usable platform admin cannot be flagged. After the window, the retention sweep hard-deletes the user record together with their memberships, external logins, passkeys, sessions, security events, push subscriptions, pending invitations to their address, profile picture and mention handles, then re-evaluates every organisation they belonged to: one left with no active members is deactivated, and one left with no active owner is flagged. Audit rows that reference the user’s id are kept and age out under the audit retention window below.
  • Deleting an organisation (at Platform → Organisations) archives its tenant database rather than dropping it — renamed aside, with a _deleted_{timestamp} suffix, so a mistaken deletion can be recovered — and erases its uploaded files from cloud storage (Azure Blob Storage or S3 — every configured provider is asked, not only the active one). Files kept in the database itself (GridFS, or the PostgreSQL / SQL Server file tables) go into the archive with the rest of its data. Nothing removes an archive afterwards, so when an organisation’s data must be erased, drop the archived database or schema yourself. If the archive cannot be made, the deletion stops and the organisation is kept as it was. Its memberships and subscription go with it; its paid payments and API usage totals are kept, as described below.
  • Encryption is a separate concern. [Encrypted] governs how a field is stored; [PersonalData] governs whether it is in the export. A field can be either, both or neither — see Security & Encryption.

Data retention

Keeping data no longer than necessary is a legal requirement, so retention is a background sweep rather than something to remember. It runs in the background worker under Full and in the web process under Lite. The windows are bucketed by kind of data, because one global number would be wrong for at least one bucket. The sweep runs every CleanupIntervalInMinutes, and the same pass completes expired account deletions. Shipped values (declared in both the Web and BackgroundWorker appsettings.json, so keep them in step):

"BackgroundServices": {
"DataRetention": {
"MaxAgeInDays": 90, // operational: notifications, email,
// webhook deliveries, inactive push subscriptions,
// SMS status-routing rows
"AuditRetentionInDays": 180, // org audit log, platform audit log, user security events
"ApiUsageRetentionInDays": 730, // daily API usage totals (counts only, no content)
"StaleSessionRetentionInDays": 30, // sessions whose sliding cookie has expired
"RevokedSessionRetentionInDays": 30, // revoked sessions — IP + device data
"PushSubscriptionMaxIdleDays": 365, // active push subscriptions with no delivery in this window
"MaxRecordCount": 10000, // row-count backstop per table (per organisation for tenant tables)
"CleanupIntervalInMinutes": 60
// plus one Enable*Cleanup flag per table, all true
},
"UserDeletion": {
"GracePeriodDays": 30
}
}

0 does not mean “keep forever” everywhere. It disables age-based pruning only where the MaxRecordCount backstop still applies — the operational tables and each organisation’s own audit log. The platform-wide tables have no row cap (sessions, user security events, the platform audit log, still-active push subscriptions, SMS status-routing rows, API usage totals), so a 0 or negative value there falls back to a bounded default instead — 180 days for the audit and SMS-routing tables, 30 for sessions, 365 for idle push subscriptions, 730 for API usage. To switch a sweep off, use its Enable*Cleanup flag, not a zero.

Only finished work is pruned: an email is removed only once the outbox has processed it, and a webhook delivery only once it is delivered, failed or abandoned. Unsent mail and in-flight deliveries are never aged out. SMS message records and SMS opt-outs are deliberately not swept — the first is the organisation’s own record, the second a regulatory artefact.

Preview links are swept by the same pass, on a fixed window with no setting: a link is deleted 30 days after it expired or was revoked. Only the token’s hash was ever stored, so what goes is the label, the creator’s id and the dates; the organisation’s own audit log keeps its record of the link being created and revoked.

The platform payment ledger (what organisations paid you, on /Platform/Billing) is swept by the same pass with a fixed rule: checkout attempts that were never paid are deleted 90 days after they started, and paid payments are never deleted — not even when the organisation is — because they are your financial record. A ledger row holds the organisation’s name, what was bought and the amounts, and no personal data.

API usage totals hold no content. Each third-party call the deployment makes or receives — AI, email, SMS, push, custom-hostname, payment-provider and app API calls, and webhooks in and out — adds to a per-day counter for its type, paying account, organisation, provider and app (and, for AI, model) — calls, failures, token counts, response time — in the platform database (gathered in memory for up to five minutes first, then written as one increment per row). No prompt, completion, message, recipient, URL or error text is stored there, so there is nothing personal to export or erase. The totals are kept after the organisation is deleted, as the operator’s cost history, until the two-year window removes them. With the ApiUsageTracking switch off (Platform Settings) nothing is counted in the database. The same counts are always sent as OpenTelemetry metrics to whatever telemetry endpoint the deployment is configured with (OTEL_EXPORTER_OTLP_ENDPOINT): the same fields, with the organisation’s id — never its name — and nothing at all when no endpoint is set. If that endpoint is a third party’s (Grafana Cloud, Datadog…), it receives your organisation ids and which providers each one uses, so list it with your other processors.

Logs, traces and the other metrics carry ids, not people. The application’s other metrics (jobs, sweeps, email, webhooks, health, billing, plugins, hostnames, cache, audit) carry no organisation at all. Log lines carry organisation, user, job and row ids — never a user’s name or email address, an IP address, a phone number or an audit row’s free-text details — and every audit event’s log copy is held to the same rule (trail, action, ids). Where a log line has to carry somebody else’s error text (a mail server refusing a message, a payment, hostname or AI provider’s answer), email addresses are replaced with [email] and a provider’s response body is reduced to its error codes.

Trace spans carry the organisation id, the request’s method, route and status, and the browser’s user-agent string. The URL path is recorded through the same redaction templates as analytics page views, so a preview link’s token or a plugin’s one-time link is replaced by its name; query-string values are always redacted. An outbound call whose URL is itself a credential — a subscriber’s webhook, an n8n webhook, a browser push endpoint — is recorded as its scheme and host only. Failure text a library writes onto a span — a database’s duplicate-key message, an error description — has email addresses replaced with [email] before any exporter sees it. The web host starts a new trace for every request rather than continuing one a caller names in a traceparent header, so an outside caller cannot attach its requests to a trace of yours or decide whether they are recorded.

All of it is sent only when OTEL_EXPORTER_OTLP_ENDPOINT is set, and kept as long as the receiving store keeps it (15 days of metrics and logs, 14 of traces, in the bundled Grafana). A plugin’s own log lines are its author’s responsibility; review them before sending logs to a third party.

A retention sweep must include deactivated organisations. An organisation is deactivated automatically when it loses its last active member, and nothing deletes it afterwards. A sweep that only visits active organisations would keep an abandoned tenant’s data — email bodies, audit rows, webhook payloads — for ever. The host’s retention sweep therefore visits deactivated organisations too, while sweeps that generate work (reminders, outbox sends) deliberately skip them. If you write a retention sweep of your own, make the same choice deliberately.

If you write a row per anonymous public request, you owe it a sweep. Anything reachable without signing in — a redirect, a public landing page, a scan or click counter — otherwise lets an unauthenticated visitor grow a tenant database without bound. Prune the per-event detail rows on a schedule and keep the aggregate counters.

Policies are pages served at /legal/{slug}. The defaults — Terms and Conditions, Privacy Policy and Cookie Policy (terms-and-conditions, privacy-policy, cookie-policy) — are seeded from DefaultOrganisationSettings:LegalPolicies, with the site name, contact email and date filled in. They are starting templates, not reviewed legal text: replace them with your own before going live. Like cookie consent they are a deployment setting with one value, edited at /Platform/LegalPolicies; organisations have no policies of their own. /legal/* is served on the application hosts only. A website on a customer hostname publishes its own privacy and cookie policies through its plugin, and the site’s banner links to those.

A policy marked Required for user consent must be accepted at registration and on an external-login sign-up. Changing a policy’s content raises the consent version, and a signed-in user whose last acceptance predates it is sent to /Account/AcceptConsent before they can continue.

A members-only application

The application is members-only for every organisation, the platform organisation included. On the platform host and on every organisation subdomain an anonymous visitor reaches only the account pages, the legal pages, the status pages, static assets, and the routes a plugin has declared public (IPublicRouteAllowList — a scan redirect, a customer portal: per-item links a member deliberately hands out); everything else, the home page included, redirects to sign-in. robots.txt answers Disallow: / there, /sitemap.xml answers 404, and /manifest.json, the favicon and the organisation details in the document head are given only to an active member of the organisation (or a platform admin). A signed-in user who is not a member of an organisation reaches nothing of it.

There is no per-organisation switch that opens an organisation’s pages on the application hosts to anonymous visitors. An organisation’s public website lives only on a customer hostname, where it is public by definition and its indexing, robots and sitemap are the site’s own (see Architecture → Custom domains and sites). See Architecture for both access walls.