Biscuit

GDPR-compliant cookie consent banner for Rails 8. Renders a configurable bottom/top banner, manages consent state via a browser cookie, and exposes a Stimulus controller for interactivity. Supports i18n and CSS custom property theming with no external runtime dependencies.

Project page · GitHub · Changelog · Issues


Requirements

Requirement Version
Ruby >= 3.2
Rails >= 8.0
Stimulus Any (via @hotwired/stimulus)
Import maps Rails default (importmap-rails)

No Sprockets, no build step. Assets are served via Propshaft (Rails 8 default).


AI-assisted setup (Claude Code)

If you use Claude Code, the quickest way to install and configure biscuit is via the built-in setup skill.

After adding the gem to your Gemfile and running bundle install, run the generator to install the skill:

rails generate biscuit:install

Then open Claude Code in your project and run:

/biscuit-install

The skill will:

  • Check your app is compatible (Ruby, Rails, Propshaft, Stimulus)
  • Mount the engine and register the Stimulus controller
  • Ask about banner position, cookie categories, and other preferences
  • Generate config/initializers/biscuit.rb from your answers
  • Scan your codebase for existing cookies and third-party tracking scripts (Google Analytics, GTM, Meta Pixel, etc.) and help you wrap them with biscuit_allowed? guards
  • Add integration tests and run them
  • Optionally commit everything

Manual installation

Add to your Gemfile:

gem "biscuit-rails"

Then:

bundle install

Setup

1. Mount the engine

In config/routes.rb:

Rails.application.routes.draw do
  mount Biscuit::Engine, at: "/biscuit"
  # ... your other routes
end

2. Register the Stimulus controller

In app/javascript/controllers/index.js:

import BiscuitController from "biscuit/biscuit_controller"
application.register("biscuit", BiscuitController)

3. Include the stylesheet

In your layout (app/views/layouts/application.html.erb):

<%= stylesheet_link_tag "biscuit/biscuit" %>

4. Render the banner

In your layout, inside <body>:

<%= biscuit_banner %>

That's it. The banner renders on every page. Once a user makes a consent choice it hides itself and shows a small "Cookie settings" link so they can revisit their preferences at any time.


biscuit_banner accepts keyword options to control behaviour per-page.

Currently there are no banner-level options. Configuration is done via the initializer — see Configuration below.


After any consent POST succeeds, the controller dispatches a biscuit:consent-given DOM event on the banner's root element. The event bubbles, so you can listen anywhere:

document.addEventListener("biscuit:consent-given", ({ detail }) => {
  if (detail.categories.analytics) {
    // GA4 Consent Mode v2 — no page reload needed
    gtag("consent", "update", { analytics_storage: "granted" })
    gtag("event", "page_view")
  }
  if (detail.categories.marketing) {
    fbq("consent", "grant")
    fbq("track", "PageView")
  }
})

detail.categories mirrors the payload sent to the server — { analytics: true, marketing: false, ... }.

If you need a full page reload after consent (for example, to trigger server-side biscuit_allowed?() guards on the current page), do it in the handler:

document.addEventListener("biscuit:consent-given", () => {
  window.location.reload()
})

Configuration

Create an initializer at config/initializers/biscuit.rb:

Biscuit.configure do |config|
  # Cookie categories — see "Custom categories" below
  config.categories = {
    necessary:   { required: true },
    analytics:   { required: false },
    preferences: { required: false },
    marketing:   { required: false }
  }

  # Name of the browser cookie that stores consent state
  # Default: "biscuit_consent"
  config.cookie_name = "biscuit_consent"

  # How long the consent cookie lasts, in days
  # Default: 365
  config.cookie_expires_days = 365

  # Cookie path
  # Default: "/"
  config.cookie_path = "/"

  # Cookie domain — nil means current domain
  # Default: nil
  config.cookie_domain = nil

  # SameSite attribute
  # Default: "Lax"
  config.cookie_same_site = "Lax"

  # Banner position: :bottom or :top
  # Default: :bottom
  config.position = :bottom

  # URL for the "Learn more" privacy policy link
  # Default: "#"
  config.privacy_policy_url = "/privacy"
end

All options at a glance

Option Default Description
categories {necessary: {required: true}, analytics: {required: false}, marketing: {required: false}} Cookie categories shown to the user
cookie_name "biscuit_consent" Browser cookie name
cookie_expires_days 365 Cookie lifetime in days
cookie_path "/" Cookie path
cookie_domain nil Cookie domain (nil = current domain)
cookie_same_site "Lax" SameSite cookie attribute
position :bottom Banner position (:bottom or :top)
privacy_policy_url "#" "Learn more" link URL

Define any categories you need. Each entry requires a :required key. Categories with required: true are shown as permanently checked and non-toggleable (necessary cookies). All others are opt-in checkboxes.

config.categories = {
  necessary:   { required: true },
  analytics:   { required: false },
  preferences: { required: false },
  marketing:   { required: false }
}

Add matching i18n keys for each custom category. For example, to add a preferences category, add to config/locales/en.yml:

en:
  biscuit:
    categories:
      preferences:
        name:        "Preferences"
        description: "Remember your settings and personalisation choices."

Biscuit ships with built-in translations for necessary, analytics, marketing, and preferences in English and French. Any other category requires you to add your own keys.


CSS Theming

All styles are scoped under .biscuit-banner. Every visual property is expressed as a CSS custom property, so you can override the entire look without touching the gem.

Available custom properties

Property Default Description
--biscuit-bg Canvas Banner background colour (browser default background)
--biscuit-color CanvasText Banner text colour (browser default text)
--biscuit-muted GrayText Secondary / description text colour
--biscuit-accent #4f46e5 Primary button background
--biscuit-accent-hover #4338ca Primary button hover background
--biscuit-border rgba(0,0,0,0.12) Divider / border colour
--biscuit-radius 0.375rem Button / panel border radius
--biscuit-font-size 0.875rem Base font size
--biscuit-font-family inherit Font family
--biscuit-z-index 9999 Stack order
--biscuit-padding 1rem 1.5rem Banner padding
--biscuit-shadow-bottom 0 -2px 12px rgba(0,0,0,0.12) Shadow when position: bottom
--biscuit-shadow-top 0 2px 12px rgba(0,0,0,0.12) Shadow when position: top
--biscuit-max-width 64rem Inner content max-width

Override example

In your application's CSS, after including the biscuit stylesheet:

.biscuit-banner {
  --biscuit-accent:       #0070f3;
  --biscuit-accent-hover: #005bb5;
  --biscuit-border:       rgba(0, 0, 0, 0.08);
}

Use the biscuit_allowed? helper, which is available in all views and layouts:

<% if biscuit_allowed?(:analytics) %>
  <!-- Google Analytics or similar -->
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
<% end %>

<% if biscuit_allowed?(:marketing) %>
  <!-- Marketing pixel -->
<% end %>

:necessary always returns true regardless of cookie state.


class ApplicationController < ActionController::Base
  def analytics_enabled?
    Biscuit::Consent.new(cookies).allowed?(:analytics)
  end
end

The consent cookie stores a JSON payload:

{
  "v": 1,
  "consented_at": "2026-03-19T10:00:00Z",
  "categories": {
    "necessary": true,
    "analytics": false,
    "marketing": true
  }
}
  • v — schema version (currently 1). Biscuit ignores cookies from unknown versions.
  • consented_at — UTC ISO 8601 timestamp of when consent was recorded.
  • categories — per-category boolean map. necessary is always true.

The cookie is not httponly so that client-side JavaScript can read consent state for lazy-loading scripts.


GDPR Notes

Biscuit provides the consent UI and storage mechanism. You are responsible for:

What Biscuit does

  • Renders a banner that requires an explicit user action before dismissal (no auto-dismiss)
  • Offers equally prominent "Accept all" and "Reject non-essential" buttons
  • Records granular, timestamped consent per category
  • Allows the user to withdraw or amend consent at any time via the "Cookie settings" link
  • Marks :necessary cookies as non-toggleable and clearly labelled
  • Writes no non-essential cookies itself — only the consent cookie, which is a functional/necessary cookie

What Biscuit does NOT do

  • It does not block third-party scripts automatically. You must conditionally load scripts based on biscuit_allowed?(:category). See the pattern below.
  • It does not implement geo-targeting (showing the banner only to EU visitors).
  • It does not store consent in a database (v1 is cookie-only).
  • It does not provide a legal opinion on whether your implementation meets GDPR requirements. Consult a lawyer.
<%# In your layout — only load analytics after consent %>
<% if biscuit_allowed?(:analytics) %>
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'G-XXXXXX');
  </script>
<% end %>

For scripts that must load on the client side after a user accepts consent during their current session (without a page reload), listen for the biscuit:consent-given DOM event and initialise scripts there.

GDPR compliance checklist

  • [x] No non-essential cookies set before consent
  • [x] Consent is freely given — equal prominence for accept and reject
  • [x] No pre-ticked boxes for non-required categories
  • [x] No dark patterns
  • [x] User can withdraw or amend consent at any time
  • [x] Consent is granular — recorded per category
  • [x] Consent is timestamped
  • [x] Necessary cookies are clearly labelled and non-toggleable
  • [x] Banner does not auto-dismiss

i18n

Biscuit ships with translations for English (en), French (fr), German (de), and Spanish (es). To add another locale, create config/locales/biscuit.<locale>.yml in your app:

pt:
  biscuit:
    banner:
      aria_label:  "Consentimento de cookies"
      message:     "Utilizamos cookies para melhorar a sua experiência neste site."
      learn_more:  "Saber mais"
      accept_all:  "Aceitar tudo"
      reject_all:  "Rejeitar não essenciais"
      manage:      "Gerir preferências"
      save:        "Guardar preferências"
      reopen:      "Definições de cookies"
    categories:
      necessary:
        name:        "Necessários"
        description: "Indispensáveis para o funcionamento do site. Não podem ser desativados."
      analytics:
        name:        "Análise"
        description: "Ajudam-nos a perceber como os visitantes utilizam o site."
      marketing:
        name:        "Marketing"
        description: "Utilizados para mostrar publicidade personalizada."
      preferences:
        name:        "Preferências"
        description: "Memorizam as suas definições e escolhas de personalização."

Engine Routes

The engine mounts two endpoints:

Method Path Action
POST /biscuit/consent Record consent for all categories
DELETE /biscuit/consent Clear the consent cookie

Both endpoints require a valid CSRF token. The Stimulus controller reads the token from the data-biscuit-csrf-token-value attribute (set automatically by the banner partial from form_authenticity_token).


License

MIT