Consentinel: A cookie consent widget I built to own my data — then open-sourced so you can own yours.
Self-hosted cookie consent on Cloudflare Workers + D1.
- v1.0.3 on npm
- 54 tests
- CI on Node 20 + 22
- Cloudflare free tier
- MIT license
Consentinel is an open-source, self-hosted cookie consent widget that runs on Cloudflare Workers. It blocks non-essential scripts until a visitor consents, logs every decision to a Cloudflare D1 database in your own account, and covers the opt-in requirements of GDPR, Chile's Ley 21.719, and Brazil's LGPD.
Many consent tools charge monthly, share a tracking layer across customer sites, and keep your consent records on servers you don't control. I wanted something small, self-hosted, and auditable — so I built one. It runs on your own Cloudflare account — the free tier covers around 100,000 consent events a day — the consent log lives in a database you own, and it's MIT, so you can fork it and make it yours.
Built around Chile's Ley 21.719 (in force December 1, 2026), but the opt-in model it uses is the same one GDPR and most Latin American laws require.
One script tag. Your database.
You drop one script tag in your site's <head>. The widget shows a consent banner in Spanish or English, blocks any script you've tagged with data-consent-category until the visitor decides, then logs the decision to a Cloudflare D1 table you own.
That last part is the point. The consent log lives in your own Cloudflare account — not mine, not a shared SaaS database. When a regulator asks for an audit trail, you pull it yourself.
At runtime it makes no external calls except the audit POST to your own Worker. The script loads from jsDelivr CDN by default; if you want zero third-party requests, install from npm and serve the bundle from your own origin.
SRI-pinned script tag
<script
defer
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/widget.js"
integrity="sha384-EcpbutKvmxCq6GRZvTqWyXb9Cb7Ka4LU4zuTSzvN1d2G3xYWus2BGn4yK4Ltv7dy"
crossorigin="anonymous"
data-api-url="https://your-worker.workers.dev"
data-lang="es">
</script>Opt-in laws. One mechanic.
Consentinel implements the opt-in model: block non-essential scripts until the visitor agrees. That's what GDPR and the ePrivacy Directive require across the EU and UK (with fines reaching up to €20M or 4% of global turnover), what Chile's Ley 21.719 requires from December 1, 2026, and what most Latin American laws — including Brazil's LGPD — are built around. The same code covers all of them, because the mechanic is the same.
What it does not do is the US opt-out model — CCPA/CPRA, "Do Not Sell," and the Global Privacy Control signal work the opposite way. That's a different mechanic and it's out of scope. If you need it, the code is MIT. Fork it.
Compatible with opt-in laws by design — not legal advice, and not a compliance certification.
GDPR / ePrivacy
EU & UK
Ley 21.719
Chile (from Dec 2026)
LGPD
Brazil
By hand or by prompt.
Installation is one script tag, or npm install galletas4all if you'd rather bundle it into your build. It's small enough and declarative enough that you don't need a dashboard, an account, or an SDK to learn.
You can paste the tag, or hand the README to your AI coding agent — Claude Code, Cursor, bolt.new, Lovable, Replit — and have it wired up in a single prompt. Shipping it as a package instead of a service is the whole point: it drops straight into whatever you're already building.
- Claude Code
- Cursor
- bolt.new
- Lovable
- Replit
Under the hood
Browser
Preact 10 renders the banner — ~3 KB of runtime; MutationObserver rewrites tagged scripts to type="text/plain" until consent is given, then swaps them back; navigator.sendBeacon sends the audit payload — survives tab close, unlike a plain fetch
The Worker
Origin allow-list validation on every request; 4 KB body cap measured from actual bytes, not the Content-Length header; Parameterized D1 insert — no SQL injection surface; GET /stats is default-deny until you set STATS_TOKEN
Audit log
Every decision writes one row: visitor ID, accepted boolean, category arrays, URL, referrer, user-agent, timestamp; Table lives in your Cloudflare account — not mine; D1 errors return 503; unexpected errors return 500
A few calls worth explaining
navigator.sendBeacon first, not fetch
The most common failure mode for consent loggers is the user accepting and immediately closing the tab. A plain fetch without keepalive gets cancelled on unload. sendBeacon survives it. I found this bug during a pre-launch audit and fixed it before shipping 1.0.2.
Content-Type: text/plain on the POST
This skips the CORS preflight round-trip — text/plain is a CORS-safelisted content type. The server still parses the body as JSON. It saves a round-trip on every consent decision — not a huge optimization, but a free one.
Default-deny on /stats
A consent widget handling privacy data shouldn't expose analytics by accident. The stats endpoint returns 503 unless you've explicitly set a bearer token. Most operators won't set one, and that's fine — the safe default is off.
Preact, not React
The whole widget is ~9 KB gzipped. Preact's runtime is around 3 KB of that. I didn't want to ship ~45 KB of React to a site that otherwise doesn't use it.
teardownCookieEnforcer()
The MutationObserver runs as long as the page exists. In an SPA that mounts the widget on every route change, you'd stack observers indefinitely. The mount() return function now tears the observer down. This was a real memory leak — the kind that only shows up in long-running SPA sessions.
What it's built with
Core
TypeScript (strict), Preact 10, esbuild 0.28
Testing
Vitest 2, jsdom, @cloudflare/vitest-pool-workers
Infrastructure
Cloudflare Workers, Cloudflare D1, Cloudflare Pages
Delivery
npm, jsDelivr CDN, GitHub Actions CI
What's next
An open-source side project, not a product I'm scaling — so the roadmap is "what I'll probably get to" plus "what I'd happily merge." It's MIT; if you need something sooner, fork it.
Nothing here is committed — these are ideas I'd happily merge. Compliance gaps come before polish: the /export endpoint for data-subject requests matters more than a theme editor.
- Google Consent Mode v2
- GTM template
- React wrapper
- Vue wrapper
- Svelte wrapper
- Astro wrapper
- WordPress recipe
- Shopify recipe
- Webflow recipe
- Ghost recipe
- CSS-variable theming
- HMAC-signed consent ledger
v1.0.3
Accessibility — focus trap, Esc to close, reduced-motion
v1.0.4
Worker /health endpoint · custom domain for the demo
v1.0.5
/export endpoint for DSAR audit trails (NDJSON, token-gated)
More on the architecture
Why Cloudflare Workers + D1, not a traditional backend?
Cloudflare's free tier covers roughly 100,000 consent events per day — more than enough for most personal or small-business sites. D1 is a SQLite-compatible database that lives in your Cloudflare account, not mine. There's no server to provision, no connection pool to manage, and the cold-start time for a Worker is measured in milliseconds. The self-hosting angle is the real reason. Any hosted consent-log backend means your audit data is on someone else's infrastructure. With D1, the data is yours by default.
How does script blocking work?
Before consent, the widget rewrites any data-consent-category-tagged scripts to type="text/plain", which the browser ignores. A MutationObserver watches for new scripts added after the widget mounts — they get the same treatment until consent is recorded. On consent, the type attribute is restored, and the browser re-evaluates the scripts. Reject follows the same flow in reverse — scripts are neutralized and any cookies that were already set are not re-set on subsequent loads.
Is the SRI hash safe to use as-is?
Each version of Consentinel ships with a SHA-384 integrity hash pinned to that exact npm bundle. The hash in the script tag above matches version 1.0.2, and jsDelivr serves the file unmodified from npm, so the browser will refuse to run anything that doesn't match. When you upgrade to a newer version, regenerate the hash from the new bundle. The README includes the command to do it.
What does 'self-hosted' mean in practice?
You deploy one Cloudflare Worker (a single TypeScript file) and one D1 table to your own Cloudflare account. Setup takes about 10 minutes following the quickstart. After that, the widget's audit POSTs go to your Worker URL — no traffic passes through anything I own. The frontend script still loads from jsDelivr by default (a public CDN), because that's the lowest-friction path. If you want zero third-party requests at all, npm install galletas4all and bundle it yourself.
stack
- TypeScript (strict)
- Preact 10
- esbuild 0.28
- Vitest 2
- jsdom
- @cloudflare/vitest-pool-workers
- Cloudflare Workers
- Cloudflare D1
- Cloudflare Pages
- npm
- jsDelivr CDN
- GitHub Actions CI