Keksdose

Hex.pmHexDocs

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:

Installation

Add the dependency to mix.exs:

def deps do
  [
    {:keksdose, "~> 0.4"}
  ]
end

Fetch and compile:

mix deps.get

Generate the migration:

mix keksdose.install
mix ecto.migrate

Configure your repo in config/config.exs:

config :keksdose, repo: MyApp.Repo

Mount 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"
    })
  });
}

consentId must 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
end

Anyone 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_nativecountry_iso, revision (nullable)
[:keksdose, :record, :rejected]counterrors
[:keksdose, :retention, :purged]count, duration_nativecutoff, 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

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