Near zero-configuration GOV.UK cookie consent banner, including Google Tag Manager loading and Google Consent Mode integration for Node.js services. Built for Defra Hapi services, with first-class Hapi, Express and Fastify integrations.
Source: https://github.com/TransformCore/govuk-analytics-consent
- GOV.UK Frontend compatible cookie banner
- Optional GOV.UK cookies-page fragment for granular per-category preferences
- Consent stored in a
govuk_analytics_consentcookie - Consent Mode defaults to denied before GTM loads
- Consent Mode updated the moment a user accepts or rejects
- GTM loaded automatically from
GTM_CONTAINER_ID - Tag presets (Google Analytics, Hotjar, Microsoft Clarity) that document their cookies, remove them on rejection and supply their CSP sources
npm install --save @transform-uk/govuk-analytics-consentRequires Node.js 20 or newer. Set the container ID if you want to load GTM:
export GTM_CONTAINER_ID=GTM-XXXXXXXChoose your framework for registration, view setup, CSP, CSRF, and rate-limiting examples:
- Hapi guide — register the plugin and inject consent into Hapi views.
- Express guide — register middleware and use
res.localsin views. - Fastify guide — register the plugin and pass its request context to views.
All three integrations register the consent routes. Set serviceName and opt into the tags your
service uses; without a configured GTM container ID, it runs un-instrumented.
| Guide | Use it for |
|---|---|
| Hapi | Plugin registration, Vision views, Blankie CSP, Crumb CSRF, and handler access |
| Express | Middleware ordering, Nunjucks locals, Helmet CSP, session-backed CSRF, and handler access |
| Fastify | Plugin ordering, explicit view context, Helmet CSP, signed-cookie CSRF, and handler access |
| Templates | GOV.UK template inheritance, manual macros, and host-provided CSP nonce locals |
| Localisation | Language selection, nested messages, per-request translators, and custom cookie/tag copy |
| Options | Complete registration options, defaults, and callback contracts |
| Development | Runnable examples, builds, and test commands |
The framework snippets are integration recipes; the example servers are runnable applications. Use the template and localisation guides for shared behaviour rather than inventing framework-specific message or nonce conventions.
CSP middleware usually runs before consent registration. Create one configuration with
createGovUkAnalyticsConsent(options), use consent.csp or merge its sources with
consent.blankieCsp(options) / consent.helmetCsp(directives), and pass the same object to the
integration. The package does not set CSP headers. For nonce and middleware-ordering examples,
see Hapi with Blankie,
Express with Helmet, or
Fastify with Helmet.
Extend govuk-analytics-consent/template.njk to add the consent banner and scripts to a GOV.UK
page. For custom layouts, manual macros, and block override examples, see the
template guide and your framework's integration guide above.
The consent POST route is not protected automatically. Use your framework's CSRF middleware to
validate the route, or provide verifyCsrfFormSubmission; returning false responds with 403.
When middleware handles validation, omit the verifier callback. getCsrfFormFields supplies
request-specific hidden fields; names and values are HTML-escaped. Both callbacks may be sync or
async.
Register the CSRF middleware before registerGovUkAnalyticsConsent (or before registering the
Hapi plugin), and make sure it protects POST {routePrefix}/consent. Do not shared-cache HTML
containing request-specific tokens. Follow the Hapi,
Express, or Fastify guide
for middleware recipes and registration order.
Rate limiting complements CSRF protection but does not replace it. The package does not impose a limit because applications may already enforce one at a reverse proxy, API gateway, or shared middleware layer. See the Hapi, Express, or Fastify guide for registration order and examples.
GTM loads from GTM_CONTAINER_ID. Each tag you run through GTM can be declared in tags; a tag
adds its cookie rows to the cookies page, removes those cookies when its category is rejected,
and contributes its origins to consent.csp. Tags are opt-in; with
no tags, only the GTM sources and the consent-cookie row are included.
import {
createGovUkAnalyticsConsent,
hotjar
} from '@transform-uk/govuk-analytics-consent'
const consent = createGovUkAnalyticsConsent({
tags: ['google-analytics', hotjar({ siteId: 1234567 }), 'microsoft-clarity']
})As with categories, each entry is either a preset name, which uses the preset's defaults, or a
tag object. Call the preset function to pass options.
| Preset name | Function | Options | Cookies | CSP origins |
|---|---|---|---|---|
'google-analytics' |
googleAnalytics() |
measurementId (defaults to GA_MEASUREMENT_ID) |
_ga, _ga_<id> |
*.google-analytics.com, *.analytics.google.com, www.google.com |
'hotjar' |
hotjar() |
siteId (defaults to HOTJAR_SITE_ID) |
_hjSessionUser_<id>, _hjSession_<id>, other _hj* |
*.hotjar.com, *.hotjar.io, wss://*.hotjar.com |
'microsoft-clarity' |
microsoftClarity() |
none | _clck, _clsk, CLID, MUID |
*.clarity.ms, c.bing.com |
Every preset function also accepts categoryId (default 'analytics'), which must match a configured
category. Without a measurement or site ID, the cookies page uses a generic <id> name; matching
for removal uses wildcards either way. Cookie copy comes from the resolved messages, so it is
localised and can be overridden. The Google Analytics explanation appears only when its tag is
configured; the analytics category description stays provider-neutral.
First-party tag cookies use 'host-and-parents' removal, because these tools set cookies on the
broadest domain they can (for example .defra.gov.uk rather than payments.defra.gov.uk). As a
result, rejecting on one service also removes those cookies for other services on the same parent
domain, which will then see the user as new. Clarity's CLID and MUID are set on Microsoft's
domains, so they are listed but never removed.
Hotjar's documentation also recommends style-src 'unsafe-inline'. It is not included; add it
yourself only if Hotjar's on-page widgets need it.
A tag is a plain object, so adding another tool needs no changes to this package:
const mixpanel = {
id: 'mixpanel',
description: 'We use Mixpanel to understand how people use the service.',
cookies: () => [
{ name: 'mp_<token>_mixpanel', match: 'mp_*_mixpanel', categoryId: 'analytics', purpose: 'Mixpanel analytics', expiry: '1 year' }
],
csp: {
'script-src': ['https://cdn.mxpnl.com'],
'connect-src': ['https://api-js.mixpanel.com']
}
}
const consent = createGovUkAnalyticsConsent({ tags: ['google-analytics', mixpanel] })description is optional plain text rendered above the tag's cookie table in its first cookie's
category. It can also be a function receiving the resolved messages for language-specific copy.
cookies receives those messages too. Tag ids must be unique. An
entry in cookies with the same name as a tag cookie overrides it.
Anyone with publish access to the GTM container can add tags to your service. To limit that, the
head snippet can push GTM's gtm.allowlist and gtm.blocklist
after the consent defaults and before the container loads. Entries are GTM tag, trigger or
variable type IDs (for example hjtc) or classes (for example customScripts):
createGovUkAnalyticsConsent({
tags: ['google-analytics'],
gtmBlocklist: ['customScripts', 'nonGoogleIframes']
})The blocklist takes precedence over the allowlist. An empty gtmAllowlist blocks every tag.
Set gtmAllowlist: 'auto' to generate the allowlist from tags, so the container can run only
the tag types you have declared:
createGovUkAnalyticsConsent({
gtmAllowlist: 'auto',
tags: ['google-analytics', hotjar({ siteId: 1234567 })]
})The generated allowlist contains:
- each tag's
gtmTypes:googtag,gaawcandgaaweforgoogleAnalytics(), andhjtcforhotjar() gtmBaseAllowlist, the built-in trigger and variable types, so existing triggers keep working. It excludes Custom JavaScript variables (jsm)
To allow further types as well, use 'auto' as one entry in the array, for example
gtmAllowlist: ['auto', 'awct']. Custom HTML tags, Custom JavaScript variables and tag types you
have not declared are blocked.
Microsoft Clarity has no built-in GTM tag type, so microsoftClarity() declares no gtmTypes.
Pass them explicitly when using 'auto'; otherwise it is an error. Gallery templates run as
sandboxedScripts, and allowlisting that class permits every custom template in the container:
microsoftClarity({ gtmTypes: ['sandboxedScripts'] })Custom tags declare their types the same way, with gtmTypes: ['...']. After enabling the
allowlist, test the container in GTM Preview mode; tags whose types are not allowlisted are
reported as blocked.
The default categories are strictly necessary cookies and cookies that measure website use. The
strictly necessary category grants security_storage; the analytics category controls
analytics_storage.
Three additional category presets are available for services that use the corresponding Google Consent Mode signals. They are not enabled by default:
| Preset | Cookies-page title | Consent Mode signals |
|---|---|---|
advertising |
Cookies that help with our communications and marketing | ad_storage, ad_user_data, ad_personalization |
functionality |
Cookies that enable additional functionality | functionality_storage |
personalization |
Cookies that remember your settings | personalization_storage |
Enable all three alongside the defaults:
import { registerGovUkAnalyticsConsent } from '@transform-uk/govuk-analytics-consent'
registerGovUkAnalyticsConsent(app, {
categories: ['default', 'advertising', 'functionality', 'personalization']
})Include only the category presets the service needs. Presets use the selected request language; custom category objects retain their own text. Tags should require the signals associated with their category in GTM so each preference actually controls whether those tags can run.
Built-in English and Welsh copy works without an i18n library. Use getLanguage to select a
service language, messages for nested overrides, or a request-bound translate callback to use
your host project's i18n catalogue. See the localisation guide for
configuration, fallback behaviour, and custom tag and cookie messages.
- The head snippet initialises
dataLayer, callsgtag('consent', 'default', …)with every signal denied pluswait_for_update, then loads the GTM container. - The deferred client script reads the consent cookie. If a choice exists it immediately calls
gtag('consent', 'update', …), and also pushes acookie_consent_updatedataLayer event so GTM tags can trigger on it. - If no choice exists the banner is shown. Accept all/reject additional writes the cookie, updates Consent Mode and swaps in the confirmation message.
- Without JavaScript the banner form posts to
{routePrefix}/consent, which sets the cookie and redirects back. Return paths are validated as same-origin, so it cannot be used as an open redirect.
The cookie is deliberately not httpOnly — the browser must read it to decide whether to
show the banner without a server round-trip.
interface ConsentState {
version: number
categories: Record<string, boolean> | null // null = no choice made; keyed by non-essential category id
updatedAt: string
}Any malformed, tampered or out-of-date cookie degrades to "no choice made" and re-prompts.
Registration exposes the parsed consent state to application handlers. In Express it is available
on req.govukAnalyticsConsent, in Hapi on request.app.govukAnalyticsConsent, and in Fastify on
request.govukAnalyticsConsent. Each Hapi,
Express, and
Fastify guide shows handler usage.
The request value is a ConsentRequestState:
interface ConsentRequestState {
state: ConsentState
hasChoice: boolean
isCategoryAccepted(categoryId: string): boolean
}hasChoice distinguishes a user who has submitted preferences from one who has not. The category
helper returns true for an accepted category and for strictly necessary categories, and false
for rejected, undecided or unknown categories. ConsentRequestState is exported for TypeScript
applications that augment their framework request type.
| Route | Purpose |
|---|---|
GET {routePrefix}/consent.js |
Browser consent manager (ETag, 1 hour cache) |
POST {routePrefix}/consent |
No-JavaScript fallback for both the banner and the cookies page |
The GOV.UK cookies-page pattern
is available as a fragment, the same way the banner is — mount your own route/view and embed it
in the content block of your view:
{% extends "govuk-analytics-consent/template.njk" %}
{% from "govuk-analytics-consent/macro.njk" import govukAnalyticsConsentCookiesPage %}
{% block content %}
{{ govukAnalyticsConsentCookiesPage(govukAnalyticsConsent) }}
{% endblock %}It lists every configured category with a table of its cookies and, under a "Change your cookie
settings" heading, a Yes/No radios group per non-essential category — matching the markup a real
govukRadios/govukButton component call would produce (cookies[{categoryId}], values yes/
no). Sensible defaults mean most services need no extra config: the consent cookie itself and
the cookies of any configured tags are documented automatically. Add entries
for your own cookies (a session cookie, for example) via cookies, and set cookiesPageUrl so
the banner links to it:
registerGovUkAnalyticsConsent(app, {
cookiesPageUrl: '/cookies',
cookies: [
{ name: 'session_id', categoryId: 'essential', purpose: 'Keeps you signed in', expiry: 'Session' }
]
})Saving stays on the cookies page (the hidden returnUrl field points back to itself) and shows a
success notification banner — matching a real govukNotificationBanner({ type: "success" }) call
— with a "Go back to the page you were looking at" link pointing to wherever the user came from.
No extra markup is needed in your main layout; it's all part of govukAnalyticsConsentCookiesPage.
See examples/views/cookies.njk and examples/views/index.njk for a full example.
Consent Mode stops GTM from setting new cookies, but it does not remove cookies that are already set. When a user rejects a category, or withdraws consent they gave earlier, the cookies documented for that category are expired:
- In the browser, straight after a banner choice and on every page load once a choice exists.
- On the server, in the consent
POSTresponse and on any later response to a request that still sends a rejected cookie. This covers users without JavaScript andHttpOnlycookies.
Nothing is removed before the user has made a choice. Each cookies entry controls this with
removeOnReject:
removeOnReject |
Behaviour |
|---|---|
'never' |
Leave the cookie alone (always the case for essential categories) |
'host-only' |
Expire it on the current host (default for non-essential categories) |
'host-and-parents' |
Expire it on the current host and every parent domain |
Use match to match cookies whose names vary. It's a glob-style where only * is a wildcard (for
example _hj* or mp_*_mixpanel); without it, name must match exactly. Mark cookies to keep
with 'never':
registerGovUkAnalyticsConsent(app, {
cookies: [
{ name: '_hj<id>', match: '_hj*', categoryId: 'analytics', purpose: 'Hotjar', expiry: '1 year' },
{ name: 'feedback_seen', categoryId: 'analytics', purpose: 'Survey', expiry: '1 year', removeOnReject: 'never' }
]
})A user-supplied entry replaces a built-in one with the same name, including its removeOnReject
and match. Only cookies set on Path=/ are expired.
All optional.
| Option | Default | Notes |
|---|---|---|
gtmContainerId |
process.env.GTM_CONTAINER_ID |
Must match GTM-XXXXXXX; omitted means the service runs un-instrumented |
cookieName |
govuk_analytics_consent |
|
cookieVersion |
1 |
Bumping it re-prompts every user. Use this if you need to ask for new consent. Details for when you might need to trigger this are in the Cookies page design pattern |
routePrefix |
/govuk-analytics-consent |
|
cookiesPageUrl |
none | Renders the banner's "View cookies" link; same-origin paths only |
consentWaitForUpdate |
500 |
Consent Mode wait_for_update in ms; false omits it |
serviceName |
this service |
Used in the banner heading |
messages |
Built-in English/Welsh copy | Nested overrides per language under govuk-analytics-consent; an absent Welsh leaf keeps its Welsh default rather than using an English override. For host-only locales, configured English copy is the fallback before built-in English. See Localisation |
getLanguage |
Accept-Language, then English |
(request, response?) => string or undefined, sync or async; called per request and takes priority over browser preferences. Returning undefined uses browser negotiation; errors propagate |
translate |
none | Synchronous per-request (key, { language, request, response, values }) => string or undefined; host result takes priority over nested messages. Return undefined or the key itself when missing; an empty string is a valid translation. Promises and global locale mutation are unsupported. See Localisation |
logger |
console |
Host logger with debug, info, warn, and error methods accepting (message, ...meta). Standard structured loggers such as Pino or Winston can be passed directly. |
categories |
essential + analytics | Built-in preset names or custom category objects; presets use the resolved messages. Add essential: true for always-on custom categories and gtagSignals for the Consent Mode signals they control |
tags |
none | Tags loaded through GTM, as preset names such as 'google-analytics' or tag objects; each adds its cookie rows and CSP origins |
gtmAllowlist |
none | GTM type IDs or classes pushed as gtm.allowlist, or 'auto' to generate it from tags; see restricting GTM |
gtmBlocklist |
none | GTM type IDs or classes pushed as gtm.blocklist; takes precedence over the allowlist |
cookies |
none | Cookie definitions or factories for the cookies page. A factory receives (messages, translate); it runs during registration and per-request localization, so custom keys need a literal fallback or configured message. Entries merge by name with built-in and tag cookies. match and removeOnReject control removal on rejection |
includeDefaultCookies |
true |
Set to false to omit the built-in consent-cookie row |
secureCookie |
NODE_ENV === 'production' |
|
cookieMaxAge |
1 year (seconds) | |
getNonce |
Blankie's script nonce in Hapi, @fastify/helmet's script nonce in Fastify, otherwise none |
(request) => string or null; called per request and applied to consent-injected scripts. The host CSP layer owns nonce generation; an explicit callback overrides automatic detection |
getCsrfFormFields |
none | (request, response?) => fields, sync or async; supplies hidden fields to both consent forms. Names and values are HTML-escaped |
verifyCsrfFormSubmission |
none | (request, body) => boolean, sync or async; used when middleware does not validate the POST. Returning false responds with 403; omit it when CSRF middleware already protects the route |
Invalid configuration (for example, a malformed GTM ID or unknown cookie category) throws during registration rather than silently enabling tracking. A literal cookie field alongside its translation key is an intentional fallback pair, not a configuration conflict. For exact message fallback behavior and factory registration timing, see Localisation.
Install dependencies once:
npm installRun one framework example at a time:
npm run example:hapi
npm run example:express
npm run example:fastifyEach command builds the package and serves the example at http://localhost:3000 by default.
Set PORT=3001 to run another example alongside it. To try GTM and Google Analytics, set
GTM_CONTAINER_ID and GA_MEASUREMENT_ID before starting an example; without them the example
still runs without loading GTM. All three examples include nonce-based CSP and CSRF protection
for the consent form. The Express session secret and Fastify cookie secret are generated at
startup for local use; set SESSION_SECRET or COOKIE_SECRET to stable secrets when needed.
Express's default in-memory session store is for examples only, not production deployments.
npm run typecheck # TypeScript checks
npm test # builds the browser bundle, then runs Vitest
npm run test:watch # Vitest watch mode
npm run test:e2e # builds the package, starts test servers, and runs Playwright
npm run build # production server and client bundlesFor a first Playwright run, install its browser with npx playwright install chromium. The E2E
command manages its own servers; you do not need to start an example server first. To run one
Vitest file, use npx vitest run test/html.test.ts.
The package is published as a public scoped npm package. The prepack script builds dist,
and prepublishOnly runs type checking and tests before publishing.
npm publish --access publicTests cover the pure consent/cookie logic, Consent Mode snippet generation and injection safety, the Nunjucks macros against the shared HTML builders, the browser manager under jsdom, and all three framework integrations end to end.