Keksdose
Drop-in cookie-consent ingestion and audit dashboard for Phoenix/Plug applications.
About the name
Keks is German for cookie; a Keksdose is a cookie jar — the kind that sits on a kitchen counter holding biscuits. The library is the jar your browser-cookie consents go into, one biscuit per visitor decision.
Browser side
Keksdose is the backend half — it has no opinion about which JavaScript library generates the consent events, only about the JSON shape they POST. The wire format documented below was designed against vanilla-cookieconsent by Orest Bida (see his blog post for the design context), and that's what we test against. Any other consent library that can be configured to emit the same payload shape will work too.
The package stores anonymous, audit-ready consent records into your existing Ecto repo as an append-only log (one row per consent event). It ships:
-
POST ingestion plug — mount wherever you
forwardto it - Paginated, country-filtered, server-rendered analytics dashboard
mix keksdose.installmigration generator-
Opt-in retention
GenServer :telemetryevents for observability<script>helper to wire the JavaScript client
Installation
Add the dependency to mix.exs:
def deps do
[
{:keksdose, "~> 0.4"}
]
endFetch and compile:
mix deps.getGenerate the migration:
mix keksdose.install
mix ecto.migrate
Configure your repo in config/config.exs:
config :keksdose, repo: MyApp.RepoMount the ingestion plug in your Phoenix router at whatever path you like:
pipeline :api do
plug :accepts, ["json"]
end
scope "/" do
pipe_through :api
forward "/api/cookie-consents", Keksdose.PlugHandler
end
The plug accepts any POST that reaches it and responds 405 to other methods.
The URL is your decision — /api/cookie-consents, /privacy, anything that
matches your app's conventions. Keep that path in mind for the client wiring
and the rate-limit rule below.
Data model
Each POST creates a new row. Consent changes from the same browser produce
additional rows sharing the same consent_id. The full history is preserved.
| Column | Required | Type | Notes |
|---|---|---|---|
id | yes | binary_id | Server-generated event ID (PK) |
consent_id | yes | binary_id | UUIDv4 the browser generates once and persists |
categories | yes | JSON list of strings |
e.g. ["necessary", "analytics"]. Column type: jsonb (Postgres) / json (MySQL) / text (SQLite). |
changed_categories | no | JSON list of strings | Delta on consent change |
revision | no | integer | Policy version at consent time (host opts in by sending one) |
language | no | string(10) |
BCP-47-ish tag, e.g. "en" |
country_iso | no | string(3) |
Resolved from CDN headers, else "XX" |
masked_ip | no | string(45) |
IPv4 last octet zeroed; IPv6 truncated to /48 |
inserted_at | yes | utc_datetime | Server-stamped |
Client integration
In your layout (passing the same mount path you put in the router):
<%= raw Keksdose.FrontendConfig.inject_script("/api/cookie-consents") %>
That script defines window.keksdoseEndpoint. In your consent JS, POST a
camelCase JSON body:
CookieConsent.run({
onFirstConsent: ({ cookie }) => transmit(cookie),
onChange: ({ cookie }) => transmit(cookie)
});
function transmit(data) {
fetch(window.keksdoseEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
consentId: data.id, // UUIDv4 persisted by the browser
categories: data.categories, // ["necessary", "analytics", ...]
changedCategories: data.changedCategories,// optional delta on change events
revision: data.revision, // optional policy version
language: data.language // optional, e.g. "en"
})
});
}
consentIdmust be a UUIDv4 that your browser-side library generates once and persists (localStorage / first-party cookie). It's what makes the audit log auditable — every consent event from the same browser shares it. If your library doesn't supply one out of the box, generate and persist a UUID yourself.
The server stamps inserted_at and ignores any client-supplied timestamp.
Security
Gate the dashboard
The dashboard plug performs no authentication. Mount it inside your admin
auth pipeline. Example with plug checks:
pipeline :admin do
plug :browser
plug MyApp.RequireAdmin
end
scope "/" do
pipe_through :admin
forward "/admin/keksdose/records", Keksdose.DashboardPlug
endAnyone reaching the plug can read every consent record, so do not skip this.
Rate-limit ingestion
The ingestion endpoint is intentionally unauthenticated (the visitor is
anonymous). Put a rate limit in front of it — these examples assume you
mounted it at /api/cookie-consents; adjust to your path.
Nginx
http {
limit_req_zone $binary_remote_addr zone=consent_ingest:10m rate=5r/s;
server {
location /api/cookie-consents {
limit_req zone=consent_ingest burst=10 nodelay;
proxy_pass http://phoenix_upstream;
}
}
}Caddy
your.domain.com {
@consent path /api/cookie-consents
rate_limit @consent {
zone consent_ingest
events 5
window 1s
burst 10
}
reverse_proxy phoenix_upstream:4000
}
(Caddy's rate_limit directive is available via the
caddy-ratelimit module.)
Application-layer alternatives: Hammer, PlugAttack, or a Cloudflare rule.
Retention
The package can periodically purge rows older than a configurable threshold.
Opt in by adding Keksdose.Retention to your supervision tree:
# config/config.exs
config :keksdose,
repo: MyApp.Repo,
retention_days: 395, # nil disables purging
retention_check_interval_ms: :timer.hours(24)
# lib/my_app/application.ex
children = [
MyApp.Repo,
Keksdose.Retention
]The GenServer waits ~60 seconds after boot before its first run, then ticks on the configured interval. To run a purge synchronously (tests, ops):
Keksdose.Retention.purge_now()Telemetry
Attach to these events:
| Event | Measurements | Metadata |
|---|---|---|
[:keksdose, :record, :inserted] | count, duration_native | country_iso, revision (nullable) |
[:keksdose, :record, :rejected] | count | errors |
[:keksdose, :retention, :purged] | count, duration_native | cutoff, retention_days |
:telemetry.attach_many(
"my-app-consent",
[
[:keksdose, :record, :inserted],
[:keksdose, :record, :rejected],
[:keksdose, :retention, :purged]
],
&MyApp.Telemetry.handle_event/4,
nil
)Database adapters
Supported out of the box. The install task detects your configured repo's
adapter and emits the right column type for categories:
| Adapter | categories column | Native? |
|---|---|---|
PostgreSQL (Ecto.Adapters.Postgres) | :jsonb | Yes |
MySQL 5.7+ / 8.x (Ecto.Adapters.MyXQL) | :json | Yes |
SQLite (Ecto.Adapters.SQLite3) | :text | Yes (with JSON1) |
| Other / unknown | :text | Fallback |
Override with --adapter:
mix keksdose.install --adapter mysql
mix keksdose.install --adapter sqlite
mix keksdose.install --adapter postgres
The Ecto schema field uses Keksdose.Types.StringList, a custom type
that JSON-encodes on write and decodes on read — so the application surface
stays the same regardless of adapter.
IP anonymization
- IPv4: the final octet is zeroed (
203.0.113.55→203.0.113.0). - IPv6: truncated to the
/48prefix — the first three 16-bit groups are preserved and the remaining 80 bits are zeroed (2001:db8:abcd:1:2:3:4:5→2001:db8:abcd::). This matches the anonymization granularity recommended by the German DPAs and is finer than the/64typically used by ISPs to identify a single subscriber.
The server always derives masked_ip from conn.remote_ip; any
client-supplied value is discarded.
In production behind a reverse proxy or CDN, install remote_ip (or equivalent X-Forwarded-For parsing) upstream of this plug so conn.remote_ip carries the real client address rather than the proxy's. country_iso is read from cf-ipcountry / x-vercel-ip-country request headers — making sure one of those is set (by fronting with Cloudflare/Vercel, or by emitting it from your own plug) is left as an exercise to the host.
License
MIT