OW Consent — Documentation
Complete consent management inside WordPress: multi-jurisdiction banner, tracker blocking, scanner, legal documents, proof ledger and rights portal. Author: OptionWeb — Julien Daniel Plugin home: https://optionweb.dev/en/addons/ow-consent/ License: GPL-2.0-or-later Version covered by this document: 1.4.3
Table of contents
- Overview
- Installation
- Quick start
- The eleven compliance profiles
- Cookie categories
- The banner
- Automatic blocking
- The tracker scanner
- The legal document generator
- The consent ledger
- The rights portal (DSAR)
- The CCPA “Do Not Sell or Share” opt-out
- Google Consent Mode v2
- IAB TCF v2.2
- Global Privacy Control
- Regional detection
- The OW Forms integration
- The floating button
- Shortcodes
- REST API
- Settings reference
- Troubleshooting
- FAQ
Overview
OW Consent is a consent management suite for WordPress. It covers the whole chain: displaying a choice, actually applying it to the trackers, keeping the proof, publishing the documents that explain it, and receiving requests from data subjects.
Eleven compliance profiles are shipped — from the GDPR to the CCPA, by way of Québec's Law 25, Brazil's LGPD or India's DPDP. The active profile decides the consent model (opt-in or opt-out), the Google Consent Mode defaults, the wording of the generated documents, the rights published and the supervisory authority cited.
Everything stays inside your site. The plugin contacts a single external service, IAB Europe's Global Vendor List, and only if you enable the TCF module, which is off by default. No telemetry, no account, no subscription.
Compatible with a full page cache, by construction
This is the architectural decision that governs everything else. Nothing the server renders depends on the consent cookie. The HTML is identical for every visitor: trackers are rewritten into inert tags for everyone, and a JavaScript runtime releases them in the browser, category by category, reading the cookie before the first paint.
Practical consequence: LiteSpeed Cache, WP Rocket, Varnish or a CDN cannot serve one
visitor's choices to another. Only genuinely personal responses — the identity
confirmation page of a rights request, the REST responses that carry visitor data — are
explicitly marked non-cacheable. No nonce is printed into cacheable HTML either: the
banner fetches a fresh one from a no-store endpoint just before each write.
What is shipped
- Consent banner: four positions, light / dark / automatic theme, “Reject all” as visible as “Accept all”, keyboard-accessible preferences panel, fully translatable.
- Automatic tracker blocking: scripts, inline snippets, iframes, pixels, resource
hints (
preconnect,dns-prefetch,preload), analytics/marketing stylesheets and third-party media, driven by 175 signatures shipped and editable. - Tracker scanner: a sweep of your own pages over WP-Cron, reading of
Set-Cookieheaders, browser probe reserved for administrators, and a panel that always says what the sweep actually covered. - Legal document generator: privacy policy and cookie policy for all eleven profiles, in the language of the jurisdiction; legal notice and terms of service / terms of sale in French.
- Consent ledger: every action appended to a table chained by HMAC, with the profile in force, the fingerprint of the published documents and the fingerprint of the banner that was actually displayed.
- Rights portal (DSAR): shortcode form, email verification, Article 12(3) deadline triggered at identity confirmation, wired into WordPress's own exporter and eraser.
- Google Consent Mode v2, IAB TCF v2.2, Global Privacy Control, regional detection, OW Forms integration: each of them can be enabled independently.
- A complete REST API under the
owc/v1namespace.
What it does not do — read this before you commit
On a legal subject, an overblown promise exposes you as much as it exposes the publisher. Here are the limits, exactly as they are written in the code.
- The generated documents are templates, not legal advice. Each one ends with a notice saying so, and that notice is on by default. Have your documents reviewed before you publish them.
- The TCF module is not a CMP registered with IAB Europe. It requires a CMP ID you have to obtain yourself, it offers no vendor-level choice, and vendors are entitled to reject its signal. If ad revenue under TCF matters to you, use a certified CMP.
- The ledger is only tamper-evident under one condition. The chain is proof only if its signing key lives outside the database. The plugin detects when that is not the case, reports it itself and tells you so in the admin instead of claiming otherwise.
- The scanner runs no JavaScript. It sees what your HTML contains and what your
Set-Cookieheaders drop; whatever a tag manager injects at runtime is only seen by a probe reserved for logged-in administrators. - The default is not “block everything third-party”. An unknown third-party script is allowed by default; it is unknown iframes that are blocked by default. Blocking rests on the signature catalogue, which you can extend.
- Without JavaScript, no choice can be recorded. The visitor sees a
<noscript>block and nothing non-essential is loaded, but nothing is recorded either. - No state-by-state granularity in the United States and none province by province in
Canada in the regional detection: the whole of the US territory gets the
ccpaprofile, the whole of Canada getsquebec. - Legal notice and terms exist in French only. For any jurisdiction whose target language is not French, the generator refuses to produce those two documents rather than publish an unsuitable text.
- No dedicated role: the entire admin requires the
manage_optionscapability.
This list is a deliberate choice, not a hidden roadmap. On compliance, a tool that states its gaps is worth more than one that hides them.
Installation
From the .zip
- Download
ow-consent-1.4.3.zipfrom https://optionweb.dev/en/addons/ow-consent/ - Plugins → Add New → Upload Plugin
- Pick the file, click Install Now, then Activate
Over FTP
Unzip the archive and drop the ow-consent folder into /wp-content/plugins/, then
activate the plugin from Plugins.
Requirements
- WordPress 6.2 or newer — this is a refusal to boot, not a recommendation (see below)
- PHP 7.4 or newer
- MySQL 5.7+ / MariaDB 10.2+
- A working WP-Cron if you use the scanner, ledger retention, DSAR deadline reminders or the TCF module
The WordPress 6.2 guardrail. Since 1.3.0, every table identifier is bound through the
%iplaceholder ofwpdb::prepare(), which WordPress only understands from 6.2 onwards. On an older core,prepare()would return an empty string, the tracker catalogue would silently resolve to an empty set, and the banner would go on promising the visitor a blocking that would never happen. So the plugin refuses to boot and shows an error that says, in as many words, that nothing is being blocked and no consent is being recorded. TheRequires at leastheader already prevents activation below 6.2, but it covers neither a core downgrade under a live install nor a copy dropped in over FTP.
What gets installed
On activation, OW Consent creates four tables:
| Table | Contents |
|---|---|
{prefix}owc_ledger | The consent ledger, chained by hashing |
{prefix}owc_dsar | Data subject requests |
{prefix}owc_scanner | Findings from the tracker scanner |
{prefix}owc_scripts | The signature catalogue used by the blocking |
A fifth table, {prefix}owc_form_links, is created separately by the OW Forms integration
if the sibling plugin is active (see The OW Forms integration).
Activation also adds:
- the
owc_settingsoption, seeded empty — deliberately: as long as you have saved nothing, every text comes from the English defaults, translated on the fly into the site's language, instead of freezing the locale of whoever activated the plugin into the database; - the
owc_versionoption; - the shipped tracker catalogue (
data/tracker-catalog.json), inserted in batches of 100 withINSERT IGNORE, behind a per-version seed flag (owc_catalog_seeded_1.4.3); - the daily
owc_daily_maintenancetask, scheduled one hour after activation.
Five further cron events are armed by the modules that use them. The six cron hooks of
the plugin are: owc_scanner_run, owc_scanner_run_batch, owc_ledger_retention,
owc_tcf_refresh_gvl, owc_run_upgrade and owc_daily_maintenance.
On multisite, tables are created per site, never shared (wp_2_owc_ledger, and so
on). A network activation walks every site only if the network has at most 200 sites;
beyond that, each site is provisioned lazily on its first request. A subsite created after
a network activation is provisioned by the wp_initialize_site hook.
What to define in wp-config.php
None of these constants is mandatory, but two of them change the evidential value of what the plugin produces.
// Recommended: moves the ledger signing key out of the database.
define( 'OWC_LEDGER_KEY', 'a long random string, unique to this site' );
// Recommended: the standard WordPress salts. Without them, WordPress stores them in the
// database, and the ledger flags itself as not tamper-evident.
define( 'AUTH_KEY', '…' );
define( 'AUTH_SALT', '…' );
// If the site sits behind a CDN, a load balancer or a reverse proxy.
// Without this constant, CF-Connecting-IP, X-Forwarded-For and X-Real-IP are IGNORED
// and only REMOTE_ADDR is used — which gives all your visitors the same identity
// for rate limiting.
define( 'OWC_TRUSTED_PROXY', '198.51.100.0/24, 2001:db8::/32' );
// `OWC_TRUSTED_PROXIES` is accepted as an alias; a string or an array both work.
// Regional detection only: declares which country headers are trustworthy.
define( 'OWC_GEO_TRUSTED_HEADERS', 'cloudflare' ); // 'cloudflare'|'cloudfront'|'proxy'|'all'
define( 'OWC_BEHIND_CLOUDFLARE', true );
define( 'OWC_BEHIND_CLOUDFRONT', true );
OWC_LEDGER_KEY is also used to derive the key of the regional detection cookie. If it is
not defined, the ledger key falls back to wp_salt('auth').
Who has access
The whole plugin admin and every admin REST route require the manage_options
capability. There is no dedicated role and no finer capability: opening the plugin
screen to someone amounts to handing them the site settings.
Upgrade path
The plugin never migrates its schema inline on an anonymous page. When the version
changes, owc_version and an owc_pending_upgrade flag are written immediately, then:
- the migration runs inline if the request is a non-AJAX admin request, a cron run or a WP-CLI command;
- otherwise an
owc_run_upgradeevent is scheduled five seconds later.
admin-ajax.php is treated as an anonymous request: it is a public entry point. The
migration runs under a lock (owc_upgrade_lock, stolen after 300 seconds), and a safety
net on admin_init catches sites where WP-Cron is disabled. On an unchanged version, the
total cost is a single get_option().
Deactivation and deletion
Deactivating keeps all data and only clears the six cron events. On a network deactivation, every site is walked in batches of 200 — unlike activation — because a cron left armed would never go away.
Deleting the plugin triggers uninstall.php, which works in two stages:
- Always, whatever your setting: the six crons are cleared and the
{prefix}owc_dsartable is dropped, along with its rate-limiting transients. It is the only table holding directly identifying personal data about other people (email address, name, free text); once the plugin is gone, nothing bounds its retention any more and no screen lets you answer, export or erase it. Export the requests you have to keep before deleting the plugin. - Only if
delete_data_on_uninstallis explicitly enabled: theowc_ledger,owc_dsar,owc_scanner,owc_scriptsandowc_form_linkstables are dropped, along with the named options, every option prefixedowc_(transients included) and everyusermetaprefixedowc_. On multisite, network options follow the main site's decision.
That setting is off by default: proof of consent, required by Article 7(1) of the GDPR, survives the deletion of the plugin.
Quick start
After activation, open the OW Consent entry in the admin sidebar. The whole plugin fits on that single screen, across ten tabs: Dashboard, Banner, Compliance, Legal identity, Categories, Policies, Scanner, Tracker catalogue, Audit ledger, DSAR requests.
Saving only affects the tab you are on. That is deliberate: every boolean has a twin hidden field, and a key missing from the form means “this field lives on another tab”, never “unticked”. Without that, saving one tab would wipe the settings of all the others.
1. Choose your compliance profile
Compliance tab. The profile decides the consent model, the Consent Mode defaults, the
documents generated and the rights published. Default: gdpr.
compliance_strict is on by default: it is what extends blocking beyond the scripts
registered by WordPress. Leave it on if you want iframes, pixels and scripts hard-coded
into your theme to be handled.
2. Fill in the legal identity
Legal identity tab. These fields are the raw material of the generated documents, and generation is refused while a required field is empty — with the list of the missing keys, not a silent failure.
Minimum for every document: legal_company_name and legal_company_email. For everything
except the cookie policy, add the address and the country. For the legal notice, also add
the phone number, the publication director and the host's full details; in France,
Belgium and Luxembourg, the legal form and the registration number become mandatory too.
Leave legal_dpa_authority empty: the supervisory authority is derived from your
country and your profile. Filling it in by hand on a multi-jurisdiction site amounts to
naming the wrong regulator.
3. Check your categories
Categories tab. All six categories are available by default. Turn off the ones
your site does not use: a category missing from the interface is not a compliance win, it
is a resource blocked forever with no switch to release it — blocking then falls back to
marketing for it.
Leave the labels empty as long as they suit you: they then follow the site's language. As soon as you customise a text, that text stops following the language.
4. Set up the banner
Banner tab. Position, theme, buttons, labels, renewal period.
Two things not to miss: leave banner_reject_all on (“Reject” must be as simple and
as visible as “Accept”), and leave the close cross off — it is absent by default
because closing without choosing amounts to an implicit refusal. If you turn it on,
clicking the cross runs the full “Reject all” path, never a silent dismissal.
5. Generate your documents
Policies tab. Four documents: cookie policy, privacy policy, legal notice, terms of service / terms of sale. Each becomes a versioned WordPress page whose link is fed back into the settings.
Generate a preview first, read it, then publish. And have it reviewed by a professional: the warning banner at the bottom of each document is not decorative.
6. Open the rights portal
Create a page and paste [owc_dsar_form] into it. Fill in dsar_email on the
DSAR requests tab: that is the contact address published under the form and the
recipient of the notifications. Without it, the plugin falls back to legal_dpo_email,
then to admin_email — but admin_email is never published on a public page.
7. Check before you open to the public
The dashboard runs fifteen compliance checks and separates errors from warnings: banner disabled, “Reject all” missing, close cross enabled, incomplete legal identity, rights portal closed, ledger disabled, no policy linked, supervisory authority contradicting the active profile (with a “Fix this” button that resets it), site not on HTTPS, uncategorised trackers, DSAR requests past their deadline, catalogue rules that cannot possibly fire.
Get those fifteen lines green before you announce that your site is compliant.
The eleven compliance profiles
The compliance profile is not cosmetic. It drives the legal model, the interface, the technical signals and the content of the published documents.
The list
| Profile | Regime targeted | Model |
|---|---|---|
gdpr | GDPR + ePrivacy (EU/EEA) | Opt-in |
uk_pecr | UK GDPR + PECR (United Kingdom) | Opt-in |
ch_nfadp | Swiss nFADP | Opt-in |
quebec | Law 25 (Québec) | Opt-in |
lgpd | LGPD (Brazil) | Opt-in |
popia | POPIA (South Africa) | Opt-in |
pipl | PIPL (China) | Opt-in |
dpdp | DPDP Act 2023 (India) | Opt-in |
ccpa | CCPA / CPRA (California) | Opt-out |
us_generic | Generic US state laws | Opt-out |
au | Privacy Act (Australia) | Opt-out |
Setting: compliance_profile, default gdpr.
Opt-in means that before any choice, only the forced categories are granted.
Opt-out means everything is granted until refusal. The three opt-out profiles are
ccpa, us_generic and au. The JavaScript runtime applies exactly the same rule as
PHP, so server and browser cannot report two different states.
What the profile actually changes
| What changes | Detail |
|---|---|
| Consent model | Opt-in, except ccpa, us_generic, au |
| Consent Mode defaults | All seven signals switch to granted under an opt-out profile |
| “Do Not Sell or Share” link required | ccpa and us_generic only |
| GPC legally binding | ccpa and us_generic only — Australia is explicitly excluded: an opt-out regime, but one that does not recognise GPC |
| “The GDPR applies” scope for TCF | The 27 EU countries + IS, LI, NO + GB + CH, i.e. 31 codes |
| Language of the generated document | quebec → French; lgpd → Portuguese; gdpr with a FR, BE or LU country → French; everything else → English |
| Supervisory authority cited | A per-profile table, refined by country for the GDPR |
| List of rights published | One list written per profile, with the article cited |
| “Cookie regime” body of the English document | Distinct text, transfer scope and safeguards per profile |
Generated documents by profile
The privacy policy and the cookie policy exist for all eleven profiles. Templates are
resolved in this order, first match wins:
<type>_<profile>_<language>, then <type>_<profile>, then <type>_<language>, then
<type>.
The English cookie policy bodies are profile-aware: the applicable rule, the transfer
scope, the safeguards and the panel wording differ for gdpr, uk_pecr, ch_nfadp,
au, pipl, dpdp, ccpa and us_generic, with the matching citations (art. 5(3) of
Directive 2002/58, PECR reg. 6, art. 45c(b) TCA and art. 19/6(7)(b) nFADP, APP 8, art. 24
PIPL, sections 5/6/7/9(3)/16 of the DPDP Act 2023, §1798.121 and Cal. Code Regs. tit. 11
§7025, VCDPA/CPA/CTDPA/UCPA/TDPSA).
Supervisory authority cited
| Profile | Authority named |
|---|---|
gdpr, country FR | CNIL |
gdpr, country BE | APD-GBA |
gdpr, country LU | CNPD |
gdpr, country DE | BfDI |
gdpr, other EEA country | Generic wording (“the competent supervisory authority”) |
uk_pecr | ICO |
ch_nfadp | PFPDT / FDPIC |
quebec | Commission d'accès à l'information |
lgpd | ANPD |
ccpa | California Privacy Protection Agency |
popia | Information Regulator (South Africa) |
pipl | Cyberspace Administration of China |
dpdp | Data Protection Board of India |
au | OAIC |
us_generic | Your state's Attorney General |
If you have edited legal_dpa_authority, your entry wins over that derivation.
Limits of the jurisdictional coverage
- Only four EEA countries have a named authority (FR, BE, LU, DE). A GDPR site in Spain, Italy or the Netherlands publishes generic wording.
- No state-by-state granularity in the United States. The
us_genericprofile exists and carries its own VCDPA/CPA/CTDPA/UCPA/TDPSA texts, but no automatic detection ever assigns it: you pick it by hand. - No provincial granularity in Canada.
- Three document languages only: French, English, Portuguese. The bodies do not go through WordPress's translation mechanism — deliberately: a legal document must be monolingual, and its language follows the jurisdiction, never the administrator's locale.
- Legal notice and terms: French templates only. The generator refuses outright for
any other target language, unless you supply your own body through the
owc_policy_templatefilter.
Cookie categories
The canonical list
Six categories, in this display order, necessary always first:
| Slug | Shipped label | Shipped description |
|---|---|---|
necessary | Necessary | Strictly required for the site to function (cart, login, language preferences). Cannot be disabled. |
functional | Functional | Enhance the experience (chat, embedded videos, maps). Without them some features may not work. |
analytics | Statistics | Help us understand how you use the site (anonymously). No personal data is shared for commercial purposes. |
marketing | Marketing | Enable us to show you ads and content tailored to your interests on other sites. |
preferences | Preferences | Remember your interface choices (layout, saved filters). |
social | Social & embeds | Allow embedded social content (YouTube, Instagram, X) to load. |
The values above are the storable English defaults. On a French-speaking site the visitor sees the translation — see the translation mechanism below.
Availability
Setting cat_<slug>_available, one per category. All six are available by default.
The old cat_<slug>_enabled key is still read as a fallback, with no migration.
preferences and social ship as available because the seed catalogue classifies
YouTube, Spotify, SoundCloud, Instagram, X and Facebook under social: without the
category in the interface, those embeds would stay blocked forever with no way to opt in.
necessary is forced: it cannot be disabled (art. 5(3) ePrivacy). So it has no
availability switch in the admin, only a label and a description.
The translation mechanism
Settings cat_<slug>_label and cat_<slug>_desc. The stored value is returned
verbatim only if it is non-empty and different from the English default; otherwise the
translation is what comes out.
Consequence worth knowing: as long as you customise nothing, changing the site's language changes the language of the categories. As soon as you type your own text, that text is frozen in the language you wrote it in. The same rule applies to the banner texts.
The translated description of necessary is built from two strings: the second one names
the plugin's own cookies, owc_consent (up to 13 months) and owc_geo (24 hours, written
only when regional detection is enabled).
Google Consent Mode v2 mapping — binding
This is the plugin's single source of truth: the banner, the blocking and the bootstrap all read this table, so they cannot drift apart.
| Category | Consent Mode v2 signals |
|---|---|
necessary | security_storage, functionality_storage |
functional | functionality_storage, personalization_storage |
analytics | analytics_storage |
marketing | ad_storage, ad_user_data, ad_personalization |
preferences | personalization_storage |
social | ad_storage, ad_user_data |
Important subtlety: if your site exposes the functional category,
functionality_storage is removed from the necessary list. Otherwise it would be
granted before any consent, even though it belongs to an optional category.
TCF mapping — descriptive only
functional → functional, analytics → measurement, marketing → advertising,
preferences → personalization, social → social_media.
These are display labels. The binding mapping on the TCF side is the table of numeric IAB purpose IDs, described in the IAB TCF v2.2 section. Never wire TCF behaviour to those five labels.
What happens when a category disappears
If a catalogue rule points at a category your site no longer exposes — you turned social
off, say — blocking falls the resource back onto marketing, or failing that onto the
first available optional category. Without that normalisation, the resource would be
blocked forever with no switch at all to release it.
The banner
When it appears
The banner is rendered on wp_footer at priority 5, and its assets are queued on
wp_enqueue_scripts. It bails out immediately if any of these is true: admin context, RSS
feed, robots.txt, or banner_enabled turned off. It also bails out if wp_head was
never fired — a theme that does not call wp_head() would otherwise get inert, unstyled
markup.
The HTML is identical for every visitor. Every switch in the panel is rendered OFF
server-side, then hydrated client-side from the cookie. It is JavaScript that sets
<html data-owc="given|none"> synchronously, before the first paint; the CSS only shows
the banner on data-owc="none".
Corollary worth knowing: a visitor without JavaScript never sees the banner, so they
are never locked behind a modal they cannot close. Instead they see a <noscript> block
explaining that their preferences cannot be recorded and that no non-essential cookie is
loaded as long as no choice has been made.
Positions and theme
| Setting | Values | Default |
|---|---|---|
banner_position | bottom-bar, bottom-card, center-modal, top-bar | bottom-bar |
banner_style | auto, light, dark | auto |
Only center-modal gets role="dialog", aria-modal="true", a dimmed backdrop, a focus
trap and the Escape key. The other three positions are a role="region": Escape is not
intercepted (the theme keeps its own handling) and focus is not stolen on first paint,
which would push a keyboard user past every skip link.
The dark palette applies under prefers-color-scheme: dark, with guards so it does not
override a theme that explicitly declares light. The site's theme can drive the palette
through CSS variables: --owc-paper-tint, --owc-ink, --owc-smoke, --owc-fog,
--owc-ui, --owc-accent, --owc-accent-strong, --owc-accent-darker.
Implementation details: z-index 99998 for the banner and 99997 for the backdrop,
support for env(safe-area-inset-*) for iOS notches, automatic offset below the WordPress
admin bar in the top position.
The buttons
| Setting | Default | Effect |
|---|---|---|
banner_accept_all | true | Shows “Accept all” |
banner_reject_all | true | Shows “Reject all” |
banner_preferences | true | Shows “Customize” |
banner_close_x | false | Shows the close cross |
banner_show_logo | true | Shows the site logo |
“Accept all” and “Reject all” share the same styling class: same background, same border, same weight, same padding. That is the answer to the CNIL / EDPB requirement (guidelines 03/2022): refusing must be as simple and as visible as accepting.
The close cross
It is off by default, because a close cross amounts to an implicit refusal. Three behaviours worth knowing:
- When
banner_close_xis off, the cross is present in the markup but carries thehiddenattribute, and the CSS removes it entirely: not visible, not focusable, not announced to screen readers. - When it is on, clicking the cross runs the full “Reject all” path, never a silent dismissal.
- It is revealed by the runtime, and relabelled, in exactly one case: when a refusal could not be recorded at all. It then refuses nothing, it puts the notice away — and that dismissal does not count as a choice. A visitor whose decision the server refuses is not left facing a banner they cannot close.
The first layer
- The title (
text_title) and the message (text_message). The message is the plugin's only rich-HTML field; it is rendered through awp_kseslimited to<a href target rel>. - The link to the privacy policy:
link_privacy_policy, with a fallback to the policy page declared in WordPress if the setting is empty. - The link to the cookie policy:
link_cookie_policy, with no fallback. - The controller's name:
legal_company_name, otherwise the site name, displayed as “Data controller: …” (art. 13(1)(a)). - The logo: the theme's custom logo at
mediumsize, otherwise the site icon.
The preferences panel
One row per available category, inside a named role="group".
- A forced category shows an “Always active” text badge, with no switch.
- An optional category shows a
<button role="switch">witharia-checked,aria-labelledbyandaria-describedby. State is carried byaria-checked, by the knob position and by a visible On/Off word — never by colour alone. - Touch target enlarged to 44 px on small screens (WCAG 2.2 AA, criterion 2.5.8).
- The panel's action bar is
stickyat the bottom, so that “Save my choices” stays reachable while the list scrolls.
If the TCF module is active, two extra blocks appear: the reachable TCF purposes and the declared special features. See IAB TCF v2.2.
Consent renewal
| Setting | Default | Bounds |
|---|---|---|
consent_renewal_months | 12 | 0 to 13 |
consent_policy_hash_check | true | — |
0 does not mean “never ask again”. The value 0 is converted to 13 months, and 13
months is the hard ceiling (CNIL deliberation 2020-091). The effective duration is
therefore always between 1 and 13 months, and it is that same value that governs the
cookie lifetime, the server-side expiry check and the duration published in the generated
documents. The OWC_COOKIE_TTL constant visible in the source is only a fallback.
consent_policy_hash_check asks for consent again when your documents change: the cookie
carries a fingerprint of the linked policy pages, and a difference reopens the banner. The
fingerprint is the empty string when no page is linked — that is what makes it
possible to tell “unknown” from “changed” and to never re-prompt a whole site on an empty
comparison.
The texts
Seven slots, all empty by default and therefore translated automatically:
text_title, text_message, text_accept_all, text_reject_all, text_preferences,
text_save, and floating_button_label for the floating button.
In the admin, each field shows its translated default as a placeholder: leaving it empty keeps the default that follows the visitor's language.
The owc_banner_texts filter lets you replace those texts in code. Extra slots are
accepted; non-scalar or empty values are dropped so that a clumsy callback cannot empty
the banner.
How the choice is actually recorded
The JavaScript runtime is strict ES5 — no arrow functions, no template literals — so it works in in-app browsers and old WebViews. Its write sequence is worth knowing, because it is what explains most of the error messages.
- Re-anchoring the origin. The REST URLs come from the WordPress configuration. If
your visitors browse on a different host (www versus apex, an alias, a preview domain,
a proxy that rewrites
Host), that URL is cross-origin and the browser refuses to store theSet-Cookiewhile WordPress answers 200. So the runtime re-anchors the path supplied by PHP onto the origin actually being browsed. - Nonce. Fetched fresh from
GET /owc/v1/noncejust before the write, never embedded in cacheable HTML, sent in theX-OWC-Nonceheader. The fetch is capped at 4 seconds, and a nonce that is missing, empty or unreachable never prevents the write and is never reported to the visitor. - Explicit
page_urlin the request body, so that the ledger row does not depend on theRefererheader, which an extension, a meta referrer or a proxy can strip. - A 15-second stall guard with
AbortController: the request is aborted, not merely ignored, so that a late POST does not write a second row. - Definition of success. The POST succeeds if and only if: the response is
ok, the body is parsable JSON,json.ok === true, and the cookie can be read back in the browser. A cache page, an edge challenge or a WAF served with a 200 do not count. - If the server answers
cookie_set: false, the runtime writes the cookie itself with the parameters returned, then reads it back. A failure stays a failure: nothing is published, nothing is unblocked. - A single retry, and only on a 403 whose code is
owc_bad_nonce,rest_cookie_invalid_nonceorrest_nonce_invalid, and only if the nonce obtained is genuinely different. - What the runtime publishes is what the server stored: categories rejected by the server are subtracted from the local state.
Until the POST has succeeded, nothing is unmasked, nothing is unblocked, and no “granted” Consent Mode signal is emitted.
The error taxonomy
The runtime distinguishes nine causes, each with its own visible message: network,
refused, ratelimit, unexpected, timeout, browser, config, cookie,
owc_cookie_not_persisted. The HTTP status and the server code are set on the element
(data-owc-status, data-owc-code) and logged once with console.warn — never rendered
as visible text. A support screenshot therefore names the cause without anyone needing a
network trace.
The banner's JavaScript API
window.OWCBanner.show(); // opens in banner mode
window.OWCBanner.hide(); // closes
window.OWCBanner.openPreferences(); // opens the preferences panel
window.OWCBanner.openDnsmpi(); // opens the panel with marketing, social
// and preferences already OFF (CCPA entry point)
window.OWCBanner.reset(); // clears the cookie client-side, posts a withdrawal,
// then reloads the page
window.OWCBanner.acceptCategory( 'social' ); // returns a promise resolved to true
// only if the server did store it
A lighter bootstrap is printed in <head> at priority 1 and exposes window.OWConsent:
.config, .categories, .geo, .profile, .optOut, .state, .has(cat),
.refresh(), .paint(), .gcmSignals(state). Its configuration can be filtered through
owc_bootstrap_config — but everything in it is public and shared by the cache:
never put anything visitor-specific in there.
Opening the panel from your own pages
Three ways, all equivalent:
- the
#owc-preferencesURL fragment (or#owc-dnsmpi), honoured on load and onhashchange; - any element carrying the
owc-open-preferencesclass or thedata-owc-openattribute; - the
data-owc-dnsmpi="1"attribute to force the “Do Not Sell” behaviour.
An owc:consent-changed event is dispatched on every change, with the submitted
payload in its detail. It is listened to by the bootstrap, the floating button, the CCPA
runtime and the TCF module; you can listen to it too.
Limits of the banner
- Everything depends on
wp_head()andwp_footer(): a theme that does not call them gets nothing. - Without
window.fetchandwindow.Promise, no write is possible and thebrowsererror is shown. - A consent can be recorded server-side without being kept browser-side (site address different from the host being browsed, storage blocked, full cookie jar). The runtime detects that case, refuses to count it as a success, shows a dedicated message and passes the fact on to the server at the next write.
- The banner has no shortcode: it shows everywhere or nowhere.
Automatic blocking
This is the module that turns a choice into a real effect. It rewrites the tags that carry trackers before any consent, for everyone, and lets the runtime release them in the browser.
Two modes, one setting
compliance_strict, on by default.
- Off (loose mode): only the
script_loader_tagfilter is hooked. In other words, only scripts registered throughwp_enqueue_script()are rewritten. A<script>hard-coded into the theme, an embed, an iframe, an<img>pixel: none of it is touched. - On (strict mode): on top of the filter, an output buffer captures the whole
document, and markup rendered through the REST API is handled as well. In the admin, if
the request is an
admin-ajax.phpone, a separate buffer covers responses that also serve the front end (“load more”, filtered archives) — but only if the handler itself declaredContent-Type: text/html.
What is never buffered
The strict buffer bails out immediately for: the admin, AJAX requests, feeds,
robots.txt, trackbacks, cron, favicons, REST requests, JSON requests, the Customizer
preview, sitemaps, and the wp-login.php, wp-register.php, wp-signup.php,
xmlrpc.php, wp-cron.php, wp-trackback.php entry points — tested on the exact script
name, never as a substring of the URL. It also bails out for scanner requests, which need
the raw HTML.
The five passes
Rewriting is done with calibrated regular expressions, never with a DOM parser. Three guardrails wrap the whole thing: a document larger than 8 MB is returned intact, a PCRE engine failure returns the document intact, and if the result is less than half the original size, the original is returned. In other words, the failure mode of blocking is “page served without blocking”, never “broken page”.
Pass 0 — masking. HTML comments, <style> and <textarea> are replaced with markers
built from control characters, so that no later pattern can match inside them.
Pass 1 — <script>. The decision, in order:
- already processed → untouched;
- the plugin's own script → untouched;
type="text/plain"or a non-JS type (ld+json,importmap,x-template) → untouched;- the URL is looked for in
src,data-src,data-rocket-src,data-lazy-src,data-litespeed-src,data-cfsrc— so the attributes used by performance plugins are covered; - host on the allowlist → untouched; a first-party resource that does not look like a tracker → untouched;
- matched against the catalogue on the
host + pathform; adata:orjavascript:URL is decoded and judged as an inline body; - a rule classified
necessaryis never blocked (Stripe.js, reCAPTCHA, Turnstile, cdnjs and the like): blocking it wins no compliance and breaks the calling snippet; - no match →
blocker_unknown_script_policy, defaultallow; - otherwise rewritten to
type="text/plain"withdata-owc-cat,data-owc-vendor,data-owc-srcand, if the original type was special (module, for instance),data-owc-type, so the restored script is not degraded.
Inline bodies are compared first against catalogue rules of type inline_signature, then
against fourteen hard-coded anchored signatures: fbq(, _fbq.push, gtag(,
dataLayer.push(, ga('…, _gaq.push, _paq.push, hjid, clarity(,
ttq.load|track|page, snaptr(, twq(, lintrk(, pintrk(. One explicit
exception: a lone gtag('consent', …) — that is, a default Consent Mode declaration
with no other marker — is still executed.
Pass 2 — <iframe>. Same URL attributes, plus data-original and data-srcset.
First-party or allowlisted → untouched. necessary → untouched, otherwise a captcha form
would become unsubmittable. No match → blocker_unknown_iframe_policy, default
block. A blocked iframe becomes src="about:blank" and is wrapped in a visual
placeholder holding an “Accept
Pass 3 — <img>, tracking pixels only. Important rule: a catalogue row on its own
is never enough to neutralise an image. If the image matches the catalogue but its
category is neither analytics nor marketing — an image CDN, a gravatar service, a font
host — it is left intact, otherwise you would be deleting the site's media rather than a
tracker. Only the tag heuristic can block: eleven known collection endpoints
(facebook.com/tr, px.ads.linkedin.com, ct.pinterest.com, bat.bing.com,
analytics.twitter.com, t.co/i/adsct, tr.snapchat.com, analytics.tiktok.com,
google-analytics.com/collect, googleads.g.doubleclick.net, pixel.quantserve.com) or
a third-party 1×1 pixel image. The src is then replaced with a transparent GIF, and
every URL-carrying attribute is removed so that a lazy loader cannot restore the
original.
Pass 4 — <link>. Conditional on blocker_block_resource_hints, default true.
Handles only hosts already present in the catalogue, never an unknown one.
- Resource hints (
preconnect,dns-prefetch,prefetch,prerender,preload,modulepreload) pointing at a catalogued third party are removed, not deferred: a hint opens a TCP+TLS connection and leaks the visitor's IP and TLS fingerprint, and there is nothing to restore afterwards. - A stylesheet classified
analyticsormarketingis neutralised. - Google Fonts and the other font hosts classified
functionalare never touched: neutralising them gives fallback-font text across the whole site, for no compliance gain whatsoever.
Pass 5 — <object>, <embed>, <source>, <video>, <audio>. Never blocked by
default: only a third party already in the catalogue is handled. The autoplay and
preload attributes are removed.
What counts as “first party”
The site URL, the WordPress URL, the content URL, the includes URL, their network
equivalents on multisite, and the uploads base. The www. is stripped for the comparison.
Any path starting with /wp-content/ or /wp-includes/ is first-party whatever the
host, to cover CDN rewrites. Non-HTTP schemes (data:, blob:, javascript:) are
never first-party.
Counter-exception: a URL containing gtag, gtm.js, analytics, pixel, fbevents,
hotjar, matomo, piwik or clarity is processed even on the site's own host.
That is what catches a self-hosted GTM or Matomo, and first-party proxies.
The unblocking runtime
Printed in <head> at priority 2. Its JSON configuration holds nothing but the cookie
name and the list of watched hosts — nothing that depends on the visitor.
- Reading the state: the
owc_consentcookie is decoded until stable, up to three passes, because cookies written before 1.4.3 were double-encoded. - Guard on dynamically injected scripts: the
HTMLScriptElement.prototype.srcsetter andsetAttributeare wrapped. A first-party loader that assignss.src = 'https://www.googletagmanager.com/gtm.js?id=…'is intercepted and the element is stamped before it runs. The watched list holds twenty hosts:googletagmanager.com,google-analytics.com,googleadservices.com,googlesyndication.com,doubleclick.net,connect.facebook.net,static.hotjar.com,script.hotjar.com,clarity.ms,cdn.matomo.cloud,analytics.tiktok.com,snap.licdn.com,sc-static.net,static.ads-twitter.com,bat.bing.com,s.pinimg.com,cdn.segment.com,js.hs-scripts.com,cdn.amplitude.com,cdn.mxpnl.com. Anything not on that list goes through untouched. The hosts on your allowlist are removed from the list before it is printed. - Re-injection in document order. A restored external script holds the queue until its
onloadoronerror, with a 5-second cap so that an unreachable vendor does not block the rest. Theasyncattribute is only applied if it was there originally — without that, a script created throughcreateElementwould be forced async and the configuration snippet would run before its library. For a restored inline script,document.writeis temporarily redirected so that it cannot wipe the document. - Consent withdrawal: if a category that was already applied explicitly turns
false, the runtime reloads the page. A script that has already run cannot be unloaded (art. 7(3) GDPR). A category simply absent from the payload is not a withdrawal. - A
MutationObserverre-scans markup injected after load (AJAX, lazy sections). - Clicking the button on a blocked embed calls
OWCBanner.acceptCategory(); if the banner's API does not exist, the embed is unblocked locally, persisting nothing.
The signature catalogue
Table {prefix}owc_scripts. The shipped file, data/tracker-catalog.json, holds
175 rows covering 71 distinct vendors:
| Breakdown | Detail |
|---|---|
| By target | 110 URL patterns, 65 cookie names |
| By type | 172 text fragments, 3 regular expressions |
| By category | necessary 55, marketing 37, functional 37, analytics 36, social 10 |
Columns: pattern, pattern_type (host, regex, inline_signature), match_target
(url or cookie), name, vendor, category, privacy_policy_url, gcm_signal,
tcf_vendor_id, retention_days.
Two behaviours to know about:
- Rows with
match_target = 'cookie'are never used by the blocking. They only feed the cookie table of the generated documents. Matching them as substrings of URLs is precisely what used to break sites' JavaScript. - A
hostpattern aimed at a URL that is shorter than six characters and has no dot is refused: “fr” or “IDE” would match “frame.js” and “provider.js”.
The catalogue is cached in the object cache (1 hour) and in a transient (12 hours). An
empty result is never cached. Every write fires the owc_catalog_updated action, which
flushes those caches.
The Tracker catalogue tab lets you add, edit, delete and search rules, with a “only rules that cannot fire” filter: empty or invisible pattern, regular expression that does not compile or that backtracks catastrophically, pattern too short and without a dot for a URL, category that no longer exists. That diagnosis is done in PHP, because only the PCRE engine can say whether an expression compiles.
Blocking settings
| Setting | Default | Values |
|---|---|---|
compliance_strict | true | boolean |
blocker_unknown_script_policy | allow | allow, block |
blocker_unknown_iframe_policy | block | allow, block |
blocker_block_resource_hints | true | boolean |
blocker_allowlist | '' | one host per line |
Extension points
// Hosts that are never blocked, on top of blocker_allowlist.
add_filter( 'owc_blocker_allowlist', function ( array $hosts ) {
$hosts[] = 'cdn.my-partner.example';
return $hosts;
} );
// Catalogue rows before validation.
add_filter( 'owc_scripts_catalog', function ( array $rows ) {
$rows[] = array(
'pattern' => 'tracker.example.com',
'pattern_type' => 'host',
'match_target' => 'url',
'name' => 'Example',
'vendor' => 'Example Ltd',
'category' => 'analytics',
);
return $rows;
} );
The public method OWC_Blocker::block_html_fragment( $html ) applies blocking to a
fragment that did not go through the output buffer.
Limits of the blocking
- Without
compliance_strict, only scripts queued by WordPress are handled. - An unknown third-party script is allowed by default. Real blocking rests on the catalogue and the inline signatures — extend them.
- The guard on JavaScript-injected scripts only covers twenty hosts; a tracker outside that list injected by first-party code goes through.
- Stylesheets classified
functional(web fonts) are deliberately let through. - A tracker catalogued as an
<img>but classified as anything other thananalyticsormarketingis not neutralised. - Media (
object,embed,source,video,audio) is never blocked by default: only a third party already in the catalogue is handled. - A document larger than 8 MB, a PCRE failure or a loss of more than 50 % of the content yields a page served with no blocking at all, silently.
- Withdrawing consent triggers a full page reload.
The tracker scanner
The scanner inventories what your pages actually load. It is off by default: turn it on from the Scanner tab.
Three detection sources
Every finding keeps its provenance and the “can blocking act on this?” flag.
1. The HTML served. The scanner fetches a sample of the URLs of your own site and
reads the markup: <script src> and <script data-owc-src> — so scripts that are already
blocked are still seen — inline signatures, <iframe>, external stylesheets and images
whose URL contains 1x1, pixel, track, beacon or impression. No JavaScript is
executed: whatever a tag manager injects at runtime is invisible to this pass.
2. The Set-Cookie headers. These are the only cookies a server-side sweep can
prove, including the HttpOnly cookies a browser probe will never see. They are
flagged unblockable: nothing in client-side blocking can hold back a cookie issued by
the server. The plugin's own cookies are ignored.
3. The browser probe. Printed in the footer only for a logged-in user holding
manage_options, and only if the scanner is enabled. It photographs document.cookie
on load, watches DOM mutations — that is what catches what a tag manager injects — polls
document.cookie every 5 seconds for client-side cookies, and sends its observations
every 2 seconds and on beforeunload. The response carrying the probe is marked
non-cacheable.
Limit acknowledged in the code: an administrator has usually already accepted everything. So the probe describes the post-consent state, not the pre-consent one.
The sweep
The URL queue is built to cover templates, not pages: the core URLs first (home, static front page, posts page, and the four linked policy pages), then a round-robin interleaving by family — up to 30 pages, 15 posts, 5 per public custom post type, 3 categories and 3 tags. Without that interleaving, thirty near-identical pages used to eat the whole budget and the WooCommerce templates were never reached.
The owc_scanner_urls filter lets you add URLs, but the result is re-restricted to the
site's host: the scanner never leaves your domain.
Every request is made with redirects followed by hand (two hops maximum, each hop
re-tested against the site's host), a response capped at 2 MB, a
Cache-Control: no-cache, no-store header and a unique URL parameter so that no cached
page is read, and an OW-Consent-Scanner/<version> user agent. A non-HTML response counts
as skipped, not as a failure.
A bypass secret is sent in the X-OWC-Scanner header so that blocking stands down and
lets the raw HTML through. It is compared in constant time, and the header is tested first
so that an ordinary visitor does not even trigger reading the option.
Budget, lock, resume
| Constraint | Value |
|---|---|
| URLs per run | scanner_max_urls, default 25, bounds 1 to 500 |
| Timeout per request | scanner_timeout, default 8 s, bounds 1 to 60 |
| Wall-clock budget per batch | max_execution_time − 10 s, otherwise 45 s, clamped between 5 and 60 s |
| Run lock | 15 minutes |
| Queue lifetime | 6 hours |
If the budget runs out, a resume is scheduled one minute later and the sweep picks up where it left off. A second launch while a sweep is running answers “a scan is already in progress”. Short circuit: if three requests fail without a single page being read, the sweep stops instead of burning its timeout on twenty-two more URLs — the typical case of a site whose HTTP loopback is blocked.
The inline signatures recognised
Twenty-three needles, grouped into twenty-one labels: gtag('config', gtag('event',
gtag('js', gtm.start, ga('create', ga('send', fbq('init', fbq('track',
_paq.push, (h.hj=h.hj, clarity('init', clarity.ms/tag, mixpanel.init,
amplitude.init, amplitude.getInstance, _linkedin_partner_id, snaptr('init',
ttq.load, pintrk("load", pintrk('load', rdt('init', criteo_q.push, _etmc.push.
The deduplication key is the signature, never a fingerprint of the body: a real gtag or Pixel snippet contains page-specific values, which would produce one row per page.
The feedback loop: finding → catalogue → blocking
This is what makes the scanner useful. Classifying a tracker writes a rule into the blocking catalogue. One click on a finding's category does three things: it writes the category onto the row, it remembers your manual decision, and it creates the matching rule. At the end of every complete sweep, the same operation is run in bulk for whatever the catalogue does not already know how to classify.
Conversion rules:
- a finding of type
cookiebecomes amatch_target = cookierule on the cookie name; - a finding of type
inline_scriptonly writes a rule if the original needle is found again. Without it, no rule is written: a rule built from the label “Google gtag (config)” would be dead forever and would match the finding's name, which would make it look “covered” forever; - otherwise, a
hostrule on the domain, refused if the domain is empty.
Your manual decisions are stored separately (500 at most) and reapplied on every new observation, because the “confirmed” flag also means “a catalogue rule matched” and so cannot carry the “the administrator has decided” information on its own. Reclassifying to “uncategorised” deletes the entry: that is a genuine undo.
The catalogue is matched longest pattern first, so that a broad rule
(google-analytics.com) does not override the more specific one you wrote
(www.google-analytics.com).
Scheduling, alerts, retention
scanner_frequency accepts hourly, twicedaily, daily and weekly — but the admin
only offers the recurrences your install actually knows about. Default: weekly. Turning
the scanner off unschedules both events.
Two distinct emails, never both at once:
- Uncategorised trackers — sent only for genuinely new identifiers, with a memory capped at 500 entries.
- “The sweep could not read this site” — limited to one message per week per error signature. This is the most serious failure mode, because a sweep that reads nothing produces no findings, and therefore no alert of the first kind.
Recipient: scanner_alert_email, otherwise the site's admin address.
Retention: 90 days. Findings not seen again for 90 days are deleted, as soon as at least one page has been read — not only when a sweep completes.
The coverage panel
This is the most important part of the Scanner tab, and it also feeds the caveat box in the generated documents. It always answers these questions:
- has a sweep ever been run?
- did the last one finish, or was it cut short by its time budget?
- how many pages were actually fetched (not merely pulled off the queue)?
- how many could not be, and what was the first error?
- has a browser ever reported anything, or has no JavaScript ever been observed?
- what is the window beyond which an unseen tracker disappears?
A sweep that reached no page at all is presented as a failure, not as a clean result. This is the panel to read before publishing a cookie policy built on those findings.
The Scanner tab
Three cards (trackers detected, uncategorised, last sweep and next run), the coverage
panel, a “run a scan now” button, the scheduling form, then the list of findings: category
filters, text search, filter by element type (script, inline_script, iframe,
stylesheet, pixel, cookie, link, preconnect), status filter, CSV export,
pagination by 25. Classify one at a time or in bulk — the bulk selector has a disabled
empty first item, so that an accidental submission does not turn into a mass
reclassification.
Scanner settings
| Setting | Default | Bounds |
|---|---|---|
scanner_enabled | false | boolean |
scanner_frequency | weekly | hourly, twicedaily, daily, weekly |
scanner_max_urls | 25 | 1 to 500 |
scanner_timeout | 8 | 1 to 60 |
scanner_probe_mode | admins | admins, off |
scanner_alert_email | '' | email address |
Worth knowing:
scanner_probe_modeis not consumed in this version. The code says so explicitly — the condition that prints the probe no longer reads this setting and relies onscanner_enabledalone. Savingoffstores the value without disabling the probe. To really stop the probe, turn the scanner off.
Limits of the scanner
- The server-side sweep executes no JavaScript. Without the probe, whatever a tag manager injects is missing from the inventory.
- The probe is reserved for logged-in administrators and there is no mode that runs it for an ordinary visitor: that would require its own prior notice.
- A cookie set through a
Set-Cookieheader is detected but unblockable. - The sweep never leaves the domain and is capped at 25 URLs by default: a large site is never covered in full.
- On a host where the HTTP loopback is blocked (staging HTTP auth, firewall), the sweep reads nothing.
- A finding not seen again for 90 days disappears, and therefore disappears from the cookie policy too.
- The scanner depends on WP-Cron: on a site with
DISABLE_WP_CRONand no system cron, the scheduled sweep never fires.
The legal document generator
The four documents
| Type | Contents | Languages available |
|---|---|---|
cookie_policy | Cookie policy | French, English, Portuguese |
privacy_policy | Privacy policy | French, English, Portuguese |
legal_notice | Legal notice | French only |
terms | Terms of service / terms of sale | French only |
Every generated document is a WordPress page, versioned through metadata:
_owc_policy_type, _owc_policy_version (incremented on every generation),
_owc_policy_hash (SHA-256 fingerprint of the HTML), _owc_policy_generated_at,
_owc_policy_profile, _owc_policy_lang, _owc_policy_manual_edit.
After generation, the link is fed back into the matching settings
(link_cookie_policy, link_privacy_policy, link_legal_notice, link_terms), and
publishing a privacy policy updates the policy page declared in WordPress.
Two locks before publishing
Lock 1 — required fields. Generation is refused with the list of the empty keys, rather than publishing clauses full of holes.
| Document | Fields required |
|---|---|
| All | legal_company_name, legal_company_email |
| Except the cookie policy | + legal_company_address, legal_country |
| Legal notice | + legal_company_phone, legal_publication_director, legal_host_name, legal_host_address, legal_host_phone |
| Legal notice, country FR / BE / LU | + legal_company_legal_form, legal_company_reg_number |
Lock 2 — the language. The generator refuses to publish a document written in a language the jurisdiction does not use. In preview, the document comes out with a red warning banner; on publication, it is a flat refusal. On the Policies tab, the generate button is hidden when no template exists for the active type and profile, rather than being shown and failing every time.
Language resolution
| Profile | Document language |
|---|---|
quebec | French |
lgpd | Portuguese |
gdpr with legal_country ∈ {FR, BE, LU} | French |
| Everything else | English |
The mandatory disclaimer
As long as policy_disclaimer is enabled — and it is by default — every document ends
with a block saying that this is an automatically generated template, that it must be
reviewed by a qualified professional before publication, and carrying the generation date
and the plugin version. Only turn it off with your eyes open.
The tracker table — the honesty rules
The table published in the cookie policy comes from your scanner table, not from a third-party database. Five rules govern what it shows.
- Freshness window: only findings seen again within the last 90 days are published. If the last-seen column does not exist yet because a migration has not run, the window is ignored rather than publishing “no trackers” on a site that has some — under-disclosure is the one direction in which a legal document must never fail.
- A caveat box at the top of the table, derived from the scanner's coverage panel: sweep never run, no page read, sweep cut short, N URLs failed, no observation with JavaScript enabled, N-day window. An incomplete sweep is disclosed, not published as a finished inventory.
- Unclassified trackers are not hidden: they get their own section. They are the ones nobody has reviewed.
- A cookie seen in a
Set-Cookieheader is flagged with a distinctive mark, with a note explaining that it is set by the server and that no client-side blocking can hold it back. So it must not be presented as conditional on consent, and the clause stating that unclassified trackers are only set after consent carries the matching exception. - The controller's name and the link to its policy come from the catalogue, only for rules targeting a URL. Cookie-name patterns are excluded: you do not assert in a legal document who processes data without proof.
The site's own cookies and the durations published
The “cookies set by this site” section lists the plugin's own cookies: owc_consent
(duration derived from the renewal setting), euconsent-v2 if TCF is enabled, owc_geo
if regional detection is enabled, owc_gpc (session duration), owc_gpc_notice
(5 minutes), plus the WordPress login and settings cookies.
The retention table takes its figures from the actual settings —
ledger_retention_days, retention_form_data_days, retention_dsar_days and the consent
renewal period. No decorative value is published. The consent cookie's duration is
derived from the same calculation as the cookie itself, which guarantees that a
setting of 0 months publishes “13 months” and not “never”.
Business type detection (terms)
The terms document has 21 sections, with seller/provider, products/services and quote/order toggles, plus clauses specific to the type of business.
legal_business_type (auto, vitrine, rental, ecommerce, services, saas,
content) is the source of truth. When it is auto, a heuristic examines the site —
presence of WooCommerce, open registration, membership plugins, a pricing page, service
pages, then keyword density over the last fifty published items — but its verdict is
never printed in a published document: it only picks the optional clauses.
French article citations are only inserted if legal_country is FR. Belgium has its own
citations (CDE art. VI.45 §1, art. VI.47, Consumer Mediation Service); Luxembourg and
Québec get neutral wording. Citing an unverified text would be the defect, not the cure.
Other behaviours
- The document's date is formatted in the document's locale. If the matching
translation pack is not installed, the format falls back to
dd/mm/yyyy: a French document cannot open on “4 September 2026”. - Manual edit detected: if the stored content no longer matches its fingerprint, a revision is saved before overwriting and the screen shows the “manual changes replaced” badge, with a link to the revisions.
- The generator never downgrades: a published page stays published even if the “publish” box is unticked, and a title or a permalink you renamed survives a regeneration.
- Injection into the footer menu:
auto_footer_menu_inject, off by default. The plugin does not change your public site without an explicit request. Locations recognised when you turn it on:footer,footer-menu,footer_menu,footer-1,footer_1,secondary,legal.
Extension points
// Raw template body, with the {{variables}} still in place.
add_filter( 'owc_policy_template', function ( $html, $type, $profile ) {
return $html;
}, 10, 3 );
// The variables injected into the template.
add_filter( 'owc_policy_vars', function ( array $vars, $type, $profile ) {
return $vars;
}, 10, 3 );
// The final HTML, variables replaced.
add_filter( 'owc_policy_html', function ( $html, array $vars ) {
return $html;
}, 10, 2 );
Supplying your own body through owc_policy_template disables the language lock: a
site that supplies its own text owns its language. This is the official way to publish a
non-French legal notice.
Four pre-generated blocks are available as reserved variables:
{{__trackers_table__}}, {{__categories_list__}}, {{__retention_table__}},
{{__jurisdictional_rights__}}. Every variable is already escaped for the context it
is used in. Three public methods can be reused by a third-party template:
OWC_Policies::build_cookie_table(), build_data_retention_table() and
render_first_party_cookies().
REST routes
| Method | Path | Parameters | Access |
|---|---|---|---|
POST | /owc/v1/policies/generate | type (required), publish (boolean, default false) | manage_options |
GET | /owc/v1/policies/preview | type (required) | manage_options |
The preview HTML goes through wp_kses_post() before being returned.
Limits of the generator
- These are templates, not legal advice. Have them reviewed.
- Legal notice and terms exist in French only. Any other jurisdiction hits the language lock, unless you supply your own body.
- Three languages only: French, English, Portuguese.
- Four EEA countries have a named authority; elsewhere, generic wording.
- The tracker table is only worth as much as your sweep — and the document says so.
- The generator class weighs roughly 440 KB of legal templates: it is loaded lazily, only when one of the three entry points is actually used.
The consent ledger
The ledger is the answer to Article 7(1) of the GDPR: being able to demonstrate that the person consented. It is on by default.
The table
{prefix}owc_ledger, all dates in UTC:
| Column | Type | Contents |
|---|---|---|
id | bigint | Primary key |
created_at | datetime | UTC timestamp |
visitor_token | char(32) | Pseudonymous browser token, 32 hex characters |
event | varchar(20) | accept_all, reject_all, save_preferences, withdraw, auto, gpc_opt_out |
categories | varchar(255) | List of the granted categories |
profile | varchar(20) | Compliance profile in force at the time of the action |
source | varchar(60) | banner, preferences, footer_link, api, auto |
ip_pseudonymous | varchar(45) | Truncated IP |
ua_hash | char(64) | Salted fingerprint of the user agent |
page_url | varchar(500) | Page where the action happened |
prev_hash | varchar(128) | Fingerprint of the previous row |
row_hash | varchar(128) | Fingerprint of this row |
policies_hash | char(64) | Fingerprint of the documents in force |
banner_revision | varchar(40) | Fingerprint of the banner actually displayed |
plugin_version | varchar(20) | Plugin version at the time of the write |
The last three columns are what make it possible to reconstruct what the visitor saw, not just what they ticked.
The chain
Every row is signed with HMAC over a canonical, length-prefixed serialisation of its content and of the previous row's fingerprint. The genesis is a run of 64 zeroes. The write happens inside a transaction, with a row lock on the last entry: it is that lock that actually serialises concurrent writes and stops the chain from forking. A MySQL named lock is taken as a second line of defence, best effort: if it is refused, the append continues and an action is fired so you can trace it.
Values are truncated before signing, so that the value signed is exactly the value stored.
Honesty about tamper-evidence
The plugin works out for itself where its signing key comes from:
| Source | Condition | Verdict |
|---|---|---|
constant | OWC_LEDGER_KEY is defined | Tamper-evident |
wp-config | AUTH_KEY and AUTH_SALT are defined, non-empty, different from each other and free of the default phrase | Tamper-evident |
database | Otherwise | Not tamper-evident |
In the third case, WordPress stores the salts in the database: anyone with database access can re-sign the chain. The plugin reports it in the verification result and shows an admin notice saying so in as many words. A non-reversible fingerprint of the key is kept, which makes it possible to tell a salt rotation from a rewrite.
That is why the Installation section recommends defining
OWC_LEDGER_KEY.
Verification
The “Verify the chain now” button on the Audit ledger tab — or the
GET /owc/v1/ledger/verify route — walks the whole chain in batches of 500 rows.
Three signature schemes are recognised: the current canonical scheme, an earlier legacy scheme, and an unkeyed scheme from the very first versions. The last one produces a fingerprint that anyone holding the database can recompute: it is never considered valid, it is counted separately and reported as a break. An unkeyed digest is not proof.
Breaks reported: hash_mismatch, chain_break, bad_genesis, unkeyed_rows,
table_emptied, tail_truncated, head_mismatch, count_mismatch.
The result carries, among others: ok, total, checked, table_total, broken_at,
breaks, break_count, partial, legacy_rows, unverifiable_rows, anchor,
anchor_ok, key_source, tamper_evident, key_rotated.
The head anchor
An anchor is kept outside the table: id, fingerprint, row count, timestamp. Without it, deleting the most recent rows or emptying the table would leave no trace at all. An action is fired every time the anchor moves, and the code explicitly invites you to replicate it outside the database — a file, syslog, an external endpoint — to make a wholesale replay detectable.
The plugin refuses to overwrite an existing anchor and states honestly that an anchor derived from the table itself only makes subsequent truncations detectable.
Retention
ledger_retention_days, default 1825 days (5 years), bounds 0 to 3650. The value 0
means unlimited retention.
Pruning runs on its own daily cron and only removes a contiguous prefix: never a hole in the middle of the chain. The fingerprint of the last deleted row is remembered so that the remainder stays verifiable. Cap per run: 20,000 rows (40 batches of 500). A very large pruning backlog therefore clears over several days.
Data minimisation
- IP address: IPv4 with the last octet zeroed (the /24 is kept), IPv6 truncated to /48 with the remaining 80 bits zeroed. Masking is done on the binary form; an IPv6 address mapping an IPv4 one is treated as IPv4.
- User agent: only a salted fingerprint is stored, never the string.
page_url: validated against the site's hosts. The caller is authoritative — if it supplies the value, even an empty one, that is the answer; theRefereris only consulted if it said nothing at all, and it is validated the same way.- Visitor token: 32 hex characters from a cryptographic generator, with no link to any identity.
The algorithm
ledger_hash_algo accepts sha256 (default) and sha3-256, intersected with the
algorithms your PHP actually supports. The setting is displayed but not editable in
the admin: changing algorithm would make verification fail for every existing row. The
columns are sized wider, but the real choice is limited to those two algorithms.
The Audit ledger tab
A list paginated by 25: id, local date and raw UTC timestamp, visitor token, event, categories, profile, pseudonymised IP, and the chain link (previous fingerprint → current fingerprint, truncated for display, full value in a tooltip).
Filters: visitor token (32 hex), event, profile, date range — entered in the site's timezone and compared against the UTC timestamps actually stored.
CSV and JSON exports. The JSON carries an envelope (format, algorithm, key source) so that an authority can re-verify the extract without holding the rest of the chain. Every CSV cell is neutralised against spreadsheet formula injection.
Reading the ledger through the API
GET /owc/v1/ledger accepts page, per_page (1 to 200, default 50), visitor_token,
from and to. The last three make it possible to answer an access request (art. 15)
without walking the whole chain. The bounds are read in UTC, and a date in YYYY-MM-DD
form is widened to the whole day. The filters actually applied are returned in the
response: a filter rejected by the checks must not read as “here is the whole ledger”.
What happens when the ledger refuses a write
This is the most important behaviour in the entire plugin. If the ledger is enabled and writing the row fails, the consent cookie is cancelled and the response is a 503. Nothing is stored, no tracker is released.
The reasoning is direct: consent you cannot prove must not be claimed. See Troubleshooting for what to do about it.
Extension points
add_action( 'owc_consent_updated', function ( $categories, $event, $source ) {}, 10, 3 );
add_action( 'owc_consent_cookie_not_sent', function ( $cookie, $event ) {}, 10, 2 );
add_action( 'owc_ledger_lock_failed', function ( $event, $token ) {}, 10, 2 );
add_action( 'owc_ledger_write_failed', function ( $error, $event, $token ) {}, 10, 3 );
add_action( 'owc_ledger_anchor', function ( array $anchor ) {} );
add_action( 'owc_ledger_pruned', function ( $deleted, $days ) {}, 10, 2 );
owc_ledger_anchor is the hook to use to replicate the anchor outside the database.
Limits of the ledger
- Tamper-evidence is conditional, and the plugin says so itself.
- Rows written before 1.2.0 carry an unkeyed digest: they are never re-signed — re-signing would let an attacker have a falsified history re-signed — and they prevent an “intact chain” verdict for as long as they remain.
- Retention is capped at 20,000 rows per daily run.
- The ledger is not deleted by WordPress's personal data eraser: it is a hash chain, removing it would destroy the proof it exists to carry, and it only holds a pseudonymous token and a truncated IP. A message explains that choice to the data subject.
The rights portal (DSAR)
The portal lets a person exercise their rights from a page on your site. It is on by default, but it only appears where you place the shortcode.
The form
[owc_dsar_form]
Attributes:
| Attribute | Default | Role |
|---|---|---|
types | access,rectification,erasure,portability,restrict,object,optout | Types offered, comma-separated |
title | “Exercise my personal data rights” | Block title |
submit_label | “Send my request” | Button label |
If dsar_enabled is turned off, the shortcode displays “the portal is disabled” and
the REST route answers 404: the switch really is server-side.
The list you ask for is intersected with the list the server accepts: an option the endpoint refuses is never offered to the visitor.
The eight request types
access, rectification, erasure, portability, restrict, object, optout,
withdraw.
withdraw is deliberately left out of what the form offers. Withdrawing cookie
consent is instant in the preferences panel; routing it through a written 30-day procedure
would make withdrawal harder than consent (art. 7(3) GDPR). A link under the form opens
the preferences panel directly for that case.
The owc_dsar_types filter is the single source of truth: the endpoint's allowlist is
derived from it.
The full cycle
1. Submission. POST /owc/v1/dsar. Three server-side protections, in this order:
- Honeypot: a filled hidden field means a 400 rejection with the generic message of a malformed submission — a bot learns nothing about why it was refused. In the browser, the form even shows the success message without sending anything.
- Attestation (art. 12(6)): the “I confirm I am exercising a right over MY OWN personal data” box must be ticked, and that is checked server-side, not only in the browser. It is stored with the request.
- A minimum 3-second delay between the form being displayed and the send, client-side.
Rate limits: 3 per hour per IP address, 3 per day per target email address — the address is chosen by the attacker, so that is the one to cap — and 30 per hour for the whole site.
The row is inserted with the pending status, a 64-character token of which only the
SHA-256 fingerprint is stored, a token expiry date and a response deadline. The id is
not returned: no enumeration, no leaking of volumes. The response is
{ok, mail_sent, message}, and mail_sent reflects a genuine send failure.
The DPO is not notified at this stage. Otherwise an anonymous caller could send two emails from your domain per submission.
2. Verification email. The link points to your home page with the plaintext token as a
parameter. Validity: dsar_token_ttl_days, default 7 days, bounds 1 to 90. The email
carries a Reply-To header but never a rewritten From — rewriting the envelope is
what breaks SPF. The reply address is dsar_email, otherwise legal_dpo_email;
never the site's admin address, which must not be published.
3. Identity confirmation. The link opens a page that executes nothing: confirmation happens through a nonce-protected POST. That is what stops an email link scanner (Safe Links, URL Defense, inbox preview) from confirming an identity on the person's behalf.
The page is a standalone HTML document, served outside the theme, with noindex, nofollow
and non-cacheable headers. Attempts are limited to 30 per hour per IP. The responses are
distinct: 404 for an unknown link, 200 for a link already confirmed, 410 for an expired
link, 403 for a stale nonce, 500 for a write failure, 200 for a confirmation.
4. What confirmation triggers.
- The status moves to
verified. - The deadline is recalculated from the confirmation. Article 12(3): the clock runs from the moment the request is complete, not from a submission that was never confirmed.
- The token is burned. Its fingerprint is kept on purpose, so that someone reopening their link reads “already confirmed” instead of a 404 telling them to start over. Single use is guaranteed by the status check.
- What is recorded: the confirmation date, the truncated IP, the pseudonymous browser token — the only possible bridge between the consent ledger and the request — and the id of the associated WordPress request.
- A native WordPress request is opened with the identity already proven, so straight
into your Tools → Export / Erase Personal Data queue. That means execution covers
every plugin on the site, not just OW Consent. Mapping:
accessandportability→ export;erasure→ erase; the other types open no native request. - The controller is notified at
dsar_emailanddsar_notify_email, falling back to the admin address if neither is valid.
5. Fulfilment. A panel above the DSAR requests tab lists the 20 verified requests, sorted by deadline, with the number of days left or overdue.
| Action | Behaviour |
|---|---|
| Download the data (JSON) | 409 refusal if the identity was never confirmed. The file is named dsar-<id>-<YYYYMMDD>.json |
| Erase the data | Offered only for a request of type erasure, and only after identity confirmation. The request is closed before the erasure, otherwise the free text of the current request would survive its own execution |
| +2 months | The Article 12(3) extension, once only, with a reason of at most 500 characters. The deadline moves back by 60 days, the reminders are re-armed, and the data subject receives an email with the new deadline and the reasons |
| Close | Status resolved or rejected, note required. A request that is already closed cannot be rewritten, and “fulfilled” cannot be recorded against an identity that was never confirmed |
| Resend the link | Issues a new token, which invalidates the previous one |
Five statuses: pending, verified, resolved, rejected, expired.
The screen never loads the token or its fingerprint, and does not display the requester's name.
6. Deadline monitoring. On the daily cron: up to 200 verified requests walked by deadline, one reminder seven days before, one reminder on overrun, each only once. An admin banner flags overdue requests and those less than seven days away.
7. Purge. Submissions that were never verified are deleted once their token expires,
and closed requests are deleted beyond retention_dsar_days. That is measured from the
closure, not from the submission.
The portability bundle
The export produces a document in the ow-consent/dsar-export format, with the timestamp,
the site, the subject, the request and the data groups. It calls every exporter
registered on the site, paginates up to 50 pages per exporter and stops at 20 seconds.
Erasure does the same on the eraser side, with the same bounds.
Filter: owc_dsar_export_bundle( $bundle, $email, $id ).
What the export and the erasure cover therefore depends on the plugins installed on your site: a plugin that registers none of those hooks has to be handled by hand.
Integration with WordPress's own tooling
- Exporter registered under the
ow-consentkey, with two groups: the rights requests and the consent ledger. - Eraser:
- submissions that were never confirmed are deleted — no evidential value, only personal data;
- closed requests have their free text and the name erased, the minimal row (date,
type, status) being kept as proof (art. 5(2)) until
retention_dsar_days; - a request still open is kept, with a message explaining that it must be answered before it can be deleted;
- the consent ledger is not deleted, for the reason explained above.
- The plugin also feeds WordPress's privacy policy draft.
The notice under the form
Below the form, a collapsible block carries the Article 13 notice: identity and address of
the controller, privacy contact, purpose and legal basis (art. 6(1)(c)), data collected,
recipients and retention period, response deadline and the possibility of an extension,
competent supervisory authority, link to the policy. Filter:
owc_dsar_form_notice( $html, $context ).
The authority displayed comes from the derivation described in The eleven compliance profiles, and is only used if it starts with a capital letter: reading the raw setting would name the French authority on a South African, Indian, Australian or Californian site.
Without JavaScript, the send button is hidden and a message offers the contact address — so that the browser never performs a native submission that would put the requester's address in the URL, and therefore in the logs of every server and proxy along the way.
DSAR settings
| Setting | Default | Bounds |
|---|---|---|
dsar_enabled | true | boolean |
dsar_email | '' | public contact and notification recipient |
dsar_notify_email | '' | additional recipient |
dsar_response_days | 30 | 1 to 30 — never more than a month |
dsar_token_ttl_days | 7 | 1 to 90 |
retention_dsar_days | 1095 (3 years) | 1 to 3650 |
Extension points
add_action( 'owc_dsar_submitted', function ( $type, $email, $message ) {}, 10, 3 );
add_action( 'owc_dsar_verified', function ( $id, array $row ) {}, 10, 2 );
add_action( 'owc_dsar_fulfilled', function ( $id, $what, $trace ) {}, 10, 3 );
add_action( 'owc_dsar_mail_failed', function ( $id, $email, $kind ) {}, 10, 3 );
Limits of the portal
- Identity rests on a single factor: the email round trip. Any additional proof under art. 12(6) is entered by hand in the request's notes.
- A failing email send blocks the cycle: the response exposes
mail_sent: falseand the form shows a message pointing to the contact address, but the request stayspendingand the legal clock does not start. - Deadline reminders and the purge depend on WP-Cron.
- The request table is always dropped when the plugin is uninstalled, whatever your setting. Export before you delete.
The CCPA “Do Not Sell or Share” opt-out
Under the US profiles, the law requires a named, visible control that actually executes the opt-out.
Two ways to place it
Shortcode, wherever you want:
[owc_dnsmpi]
[owc_dnsmpi label="Do not sell or share my personal information" class="my-link"]
| Attribute | Default |
|---|---|
label | “Do Not Sell or Share My Personal Information” |
class | owc-dnsmpi |
Automatic footer injection: the ccpa_inject_footer setting, on by default,
rendered on wp_footer at priority 20.
What a click does
One click executes the opt-out. It does not open a panel. The runtime fetches a fresh
nonce then sends a reject_all with the footer_link source and every optional category
set to false. The link switches to aria-busy="true" and a role="status" region shows
the state: “saving”, then “your opt-out has been recorded on this browser”, or the error
message.
The code cites the reason: under the CCPA implementing regulations (§7026(a)(1)), a link that merely opens a panel is not a compliant mechanism.
Fallback without fetch or Promise (old WebView, in-app browser): the click opens
the preferences panel with the advertising categories already set to off.
The cache constraint
The HTML served is the same for everyone. So the link is rendered visible if the
site's configured profile is ccpa or us_generic; otherwise it is rendered hidden and
revealed client-side for visitors whose jurisdiction requires it, based on the regional
detection cookie.
If regional detection is disabled and your configured profile is not a US one, the block is simply not printed at all: no dead markup.
No visitor data travels in this module's JavaScript configuration — only the lists of profiles, never the resolved boolean.
This module's assets are printed in <head>, not in the footer, so that the Consent Mode
refusal reaches gtag before a tag manager fires. The JavaScript is ES5 only.
Limits
- The one-click control requires
fetchandPromise. - The link is required only under the
ccpaandus_genericprofiles; under any other profile it is neither displayed nor revealed. - Client-side revealing depends on the regional detection cookie, and therefore on JavaScript.
Google Consent Mode v2
The module is on by default (gcm_enabled).
The seven signals
All seven Consent Mode v2 signals are emitted: ad_storage, ad_user_data,
ad_personalization, analytics_storage, functionality_storage,
personalization_storage and security_storage. The plugin's six categories are mapped
to them from a single source of truth, which guarantees that the banner, the blocking
and the bootstrap cannot drift apart. The table is given in
Cookie categories.
The defaults, before any consent
The default call is printed as static markup, and the update is computed in the
browser: that is what keeps the page cacheable.
| Signal | Opt-in regime | Opt-out regime |
|---|---|---|
ad_storage | denied | granted |
analytics_storage | denied | granted |
ad_user_data | denied | granted |
ad_personalization | denied | granted |
personalization_storage | denied | granted |
functionality_storage | denied if the site exposes the functional category, otherwise granted | granted |
security_storage | granted always | granted |
Both sets are printed in the same page. The runtime picks which one to apply based on the regional detection cookie. Without that, a cached page would pin an American visitor's jurisdiction onto a European one.
The two related settings
| Setting | Default | Effect |
|---|---|---|
gcm_ads_data_redaction | true | Redacts advertising identifiers while ad_storage is denied |
gcm_url_passthrough | true | Passes gclid / dclid through URLs while cookies are denied |
Worth knowing
security_storage is never touched by an update: it stays granted, as the specification
provides. The dashboard shows a card saying whether Consent Mode is active.
IAB TCF v2.2
The TCF module is off by default, and it requires an identifier the plugin cannot supply. Read the limits section before you enable it.
Enabling it
| Setting | Default | Bounds |
|---|---|---|
tcf_enabled | false | boolean |
tcf_cmp_id | 0 | 0 to 4095 |
tcf_publisher_country | FR | 2-letter ISO code |
tcf_publisher_purposes_li | [] | list of purposes |
tcf_special_features | [] | list of special features |
The 4095 ceiling is not arbitrary: the CmpId field takes 12 bits in the TCF string.
Three cases where the module refuses to emit anything
- No CMP ID (
tcf_cmp_id < 1): no__tcfapi, no TC string, noeuconsent-v2cookie, no REST route. An admin notice explains it. The CMP ID has to be assigned to you by IAB Europe; the plugin does not supply one. Emitting a string carrying CmpId 0 would be worse than emitting nothing. - CMP ID above 4095: the same refusal, with its own notice. A value truncated to 12 bits would designate another CMP — an impersonation.
- No Global Vendor List cached: the stub is printed but the API answers
cmpStatus: 'error'with an empty string, rather than inventing a list version number.
A diagnosis is written into an option (disabled, missing_cmp_id, no_gvl, active),
and only in an admin or cron context, never on a public page.
The Global Vendor List
- Source:
https://vendor-list.consensu.org/v3/vendor-list.json. - Never downloaded while a page is being rendered. A daily cron handles it, with a first run five minutes after activation; in the admin with a cold cache, a single fetch is queued.
- Request: 5-second maximum timeout, 2 redirects, response capped at 4 MB. A body that reaches the cap is considered truncated and rejected.
- Only the useful fields are kept per vendor; the stacks are emptied. If the serialisation is still too large, two truncation tiers apply: first the labels and URLs, then a reduction to purposes only.
- Retention: one week in a transient, with stale-if-error — on failure, the last valid copy is kept.
- Public route:
GET /owc/v1/tcf/gvl, limited to 10 requests per hour. It serves the cached copy with anETagand aCache-Control: public, max-age=86400, and handles conditional requests. If nothing is cached, it answers 503 with aRetry-After: 300— never a synthetic list. That route never triggers an outbound request.
Where the TC string is computed
In the browser, not in PHP. A fully static stub is printed in <head> at priority 0:
locator iframe, window.__tcfapi implementation, postMessage relay, and a Core segment
encoder in JavaScript. The published configuration contains no visitor data — that is
what keeps it compatible with a page cache. Filter: owc_tcf_stub_config.
A PHP mirror of the encoder exists but has no caller in the plugin: it is reserved for integrations and tests, with the warning never to print it into cacheable HTML.
What is encoded
Core segment only, in base64url without padding. The vendor section is encoded either as a bit field or as ranges: both sizes are measured and the smaller wins. The vendor index sent to the browser uses a compact in-house format, with a hard ceiling beyond which the index is emptied (vendor consents are then lost, the list version is kept).
Category → TCF purpose mapping
| Category | Purposes |
|---|---|
necessary | none — outside the TCF scope, which guarantees that “Reject all” cannot produce a consent for purpose 1 |
functional | 1 |
analytics | 1, 8, 9, 10 |
marketing | 1, 2, 3, 4, 7 |
preferences | 1, 5, 6, 11 |
social | 1 |
Filter: owc_tcf_purpose_map.
Publisher legitimate interest: only purposes 2, 7, 8, 9, 10 and 11 are kept,
because TCF v2.2 forbids legitimate interest for purposes 1, 3, 4, 5 and 6. Any other
value in tcf_publisher_purposes_li is silently dropped.
A vendor is consented as soon as at least one of the purposes it declares under the “consent” basis is granted.
The special features
Only two entries are supported: 1 — use of precise geolocation data and 2 — actively
scanning device characteristics. Declare the ones you use in tcf_special_features; the
list is narrowed to that catalogue.
They appear as real checkboxes in the preferences panel. “Accept all” does not tick them: they require their own explicit opt-in. “Reject all” and “Do Not Sell” untick them. They live in the TC string itself, and are rehydrated from the TCF API when the panel is reopened.
The TCF layer in the panel
It is only rendered if the module is active, that is tcf_enabled and a usable CMP ID.
Two blocks:
- Purposes: only those reachable through a non-forced category and named in the vendor list catalogue. A switch that flipped back on its own would be worse than no switch. Toggling a purpose writes to every category that declares it, then the display is re-derived.
- The declared special features.
Nothing is encoded into the string that was not displayed.
The JavaScript API
window.__tcfapi( command, version, callback, parameter );
// commands: ping, getTCData, getInAppTCData,
// addEventListener, removeEventListener, getVendorList
window.__owcTcfUpdateState( tcData ); // replaces the data and notifies listeners
window.__owcTcfRefresh(); // recomputes and emits 'useractioncomplete'
window.__owcTcfUiShown(); window.__owcTcfUiHidden();
window.__owcTcfSetSpecialFeatures( [ 1, 2 ] );
Versions accepted: absent, null, 2, '2', 2.2, '2.2'. Any other value gives
callback(null, false).
gdprApplies
Decided client-side. If regional detection is disabled, the value is true.
Otherwise, the visitor's country is compared against the list of the 31 codes where the
GDPR applies in the TCF sense (the 27 EU countries, plus IS, LI, NO, GB and CH).
Fails closed: an unknown country gives true.
The euconsent-v2 cookie
Written client-side only, and only when the module is ready and the visitor has
acted. Otherwise the cookie is cleared. Its lifetime is that of the consent, capped at
13 months (IAB / CNIL recommendation). SameSite=Lax, Secure over HTTPS.
Limits of the TCF module — essential reading
- This is not a CMP registered with IAB Europe. It requires your own CMP ID, and even with one, TCF policy requires a registered CMP to present choices at purpose level and at vendor level. Here the purpose switches follow the categories and there is no vendor-level choice: vendors are entitled to reject this signal. The admin screen says so and calls it “CMP-compatible mode (unofficial)”, accepted by most SSPs in development but rejected in real production in the EEA. If ad revenue under TCF matters to you, use a certified CMP.
- Core segment only: no
disclosedVendors,allowedVendorsorpublisherTCsegment. - No publisher restriction is emitted: the matching counter is always 0 and the restrictions object is empty.
purposeOneTreatmentanduseNonStandardTextsare alwaysfalse,isServiceSpecificis alwaystrue— there is no global scope — and the consent screen is always 0.getVendorListignores the version parameter and always returns the cached list.- Publisher consents copy the general purposes: no custom purpose.
- Two special features only.
- The module requires a working cron and outbound HTTPS. Without a cached list, the
API answers
cmpStatus: 'error'and the public route returns 503.
Global Privacy Control
GPC is a signal sent by the browser — the Sec-GPC: 1 header and the
navigator.globalPrivacyControl property. The plugin honours it by default (gpc_honor),
but how it is treated depends on the legal regime, and that is the important part.
Under the US profiles: binding
The signal is treated as a binding universal opt-out under the ccpa and us_generic
profiles. Australia is explicitly excluded: an opt-out regime, but one that does not
recognise GPC.
The server-side write only happens after a complete chain of checks, in this order:
gpc_honoris enabled;- the signal is present — the
Sec-GPCheader, with a fallback toX-Sec-GPCbecause some proxies and CDNs rename the header; only the exact value1counts; - the effective profile is
ccpaorus_generic— otherwise nothing is written; - it is a plain page view: no admin, no cron, no AJAX, no REST, no XML-RPC, no WP-CLI,
and the method is
GET; - headers have not been sent yet — a cookie you cannot set is a decision you would re-record on every request;
- the
owc_gpcsession marker is absent: once per browsing session; - the visitor does not look like a bot (empty user agent, or containing
bot,crawl,spider,slurp,monitor,uptime,pingdom,headless,preview,curl/,wget,python-,java/,go-http,okhttp,httpclient,libwww,facebookexternalhit); - the choice already stored does not already satisfy the signal — otherwise the browser is merely marked;
- the shared rate limit is respected: 30 per hour per IP;
- the browser is marked before the write, so that a failure does not turn into a replay loop.
The event is recorded as gpc_opt_out with the auto source. The page URL is
reconstructed from the site URL and the requested path — never from the Host header,
never from the Referer.
Scope: every optional category goes to false; forced categories stay granted. That
is a broad reading of what selling or sharing means.
Transparency: if GPC overrides an explicitly recorded choice, a five-minute cookie triggers a client-side notice at the bottom of the screen explaining it, with a “manage my preferences” button that opens the panel, and a close button.
Under the GDPR and similar profiles: a hint only
Outside the US opt-out regimes, the signal is treated as an indication, never as consent:
- the
data-owc-gpc="1"attribute is set on<html>; - Consent Mode sets
ad_storage,analytics_storage,ad_user_data,ad_personalizationandpersonalization_storagetodenied; - every optional category is set to
falsein memory only; givenstays false, the banner stays on screen, and nothing is recorded.
An internal flag stops the runtime from telling the visitor they are “already opted out” when neither the cookie nor the ledger says so. The justification is written in the code: GPC is not a legally recognised signal under the GDPR and ePrivacy, the Swiss nFADP, the LGPD, POPIA, PIPL, DPDP or Law 25.
The two cookies this module sets
| Cookie | Lifetime | Role |
|---|---|---|
owc_gpc | session | Marks that the signal has already been handled for this browser |
owc_gpc_notice | 300 s | Triggers the “GPC replaced your choice” notice |
Both are on path=/, SameSite=Lax, Secure over HTTPS, and readable by script.
Extension point
add_action( 'owc_gpc_honored', function ( array $categories ) {} );
Regional detection
Regional detection applies each visitor's own jurisdiction profile rather than the site's configured profile. It is off by default.
| Setting | Default |
|---|---|
geo_enabled | false |
geo_default_profile | gdpr |
geo_mmdb_enabled | false |
The detection cascade
First success wins:
- Cloudflare (
CF-IPCountry) — only if the site has declared that it is behind Cloudflare; - AWS CloudFront (
CloudFront-Viewer-Country) — same condition; GEOIP_COUNTRY_CODE, the variable written by the server itself (mod_geoip, ngx_http_geoip), and therefore not forgeable by the client; then, if a proxy is declared, the HTTP header variants;- A local MaxMind database, if
geo_mmdb_enabledis on; - Nothing.
Without an explicit declaration in wp-config.php, no HTTP country header is
believed: they are forgeable by the client. See Installation for the
constants.
The country code is validated against the officially assigned ISO-3166-1 alpha-2 allowlist
(roughly 249 codes enumerated in the code). User-assigned or reserved codes — XX, ZZ,
the T1 of Tor exits — are rejected.
Failing closed
With no country detected, the plugin reads geo_default_profile but downgrades any
opt-out profile to gdpr. A geo_default_profile set to ccpa, us_generic or au is
therefore ignored for undetected visitors — and the admin screen only offers opt-in
profiles in that selector, rather than offering a choice that would be silently ignored.
The reason is direct: without that guardrail, a site configured in American English would put every EEA visitor into an opt-out regime, with trackers flagged “granted” without any consent at all.
No fallback to the site's locale: a site's language says nothing about where its visitor is.
The owc_geo cookie
- Lifetime 24 hours,
path=/,SameSite=Lax,Secureover HTTPS, readable by script — the front-end bootstrap needs it. - Contents: the country, the profile, a timestamp and an authentication code. Never an IP address.
- On read: size bounded, country validated against the ISO list, timestamp within the window, code verified in constant time. The profile is always recomputed server-side from the country: a visitor cannot choose their own legal regime.
- Only a real detection is cached. The “fail closed” fallback is never memorised.
- Written only on a front-end request, and only if detection is enabled.
The country → profile mapping
| Country | Profile |
|---|---|
| The 30 EEA countries | gdpr |
| GB, JE, GG, IM | uk_pecr |
| CH | ch_nfadp |
| BR | lgpd |
| ZA | popia |
| CN, HK | pipl |
| IN | dpdp |
| AU, NZ | au |
| CA | quebec |
| US | ccpa |
| Everything else | The fallback, downgraded to opt-in |
Hong Kong actually falls under its own local ordinance; it is treated as PIPL out of
strictness. New Zealand is treated like Australia. The whole of Canada gets quebec — the
strictest regime wins. The United States gets ccpa, which stands in as the profile for
the other states.
The cache constraint — what detection does not do
The geolocated profile does not change the rendered HTML. The profile override only
applies to the two visitor REST routes, /owc/v1/consent and /owc/v1/state. Page
rendering, the admin routes, wp-admin, cron and WP-CLI keep the configured profile.
Any response that consumed the override is marked non-cacheable.
It is the front-end runtime that picks the regime, client-side, from the cookie. The trade-off is owned: the regime applied to a visitor depends on JavaScript, and a visitor whose very first page comes from the cache is handled under the site's configured profile for as long as the cookie does not exist.
The built-in MaxMind reader
The plugin embeds its own MMDB reader, written in pure PHP with no Composer dependency — a plugin published on WordPress.org cannot bundle the official package.
- Read-only, country only. No city, no ASN.
- Bounded I/O: reading in small blocks, never loading a multi-megabyte file into memory.
- Never throws: a file that is missing, unreadable, truncated, corrupted or hostile
yields
null, and detection falls back to its strict profile. - Internal guardrails on traversal depth, decoding budget and payload size. The metadata is cached in a transient whose key includes the file's size and date: replacing your monthly download invalidates the cache on its own.
registered_countryandrepresented_countryare deliberately ignored. That is the documented shape of anonymous proxy, VPN and satellite ranges, where the registered country is where the provider registered the block, not where the visitor is. Using them would put an EEA visitor behind an American VPN into an opt-out regime.- If another plugin already loads a GeoIP2 reader, it is used as a fallback after the in-house reader.
Where to put the file: wp-content/uploads/ow-geoip/GeoLite2-Country.mmdb. The path
can be filtered through owc_geo_mmdb_path, with validation against directory traversal.
Important limit: the plugin does not download the database and offers no upload screen. You have to obtain the file from MaxMind, drop it in yourself and update it yourself. In exchange, nothing is sent to MaxMind: the lookup is entirely local, the IP address serves only as the lookup key and is explicitly destroyed afterwards — never logged, never stored, never written into the cookie.
Limits of regional detection
- Off by default, and the MaxMind variant requires a file you supply.
- Country headers are ignored until you have declared your proxy.
- No state-by-state granularity in the United States, nor province by province in Canada.
- The visitor's regime depends on JavaScript and on the 24-hour cookie.
- The
us_genericprofile is never assigned automatically.
The OW Forms integration
Exact scope: this integration targets the sibling plugin OW Forms, and nothing else. There is no Contact Form 7, Gravity Forms or WPForms integration.
Setting: forms_integration_enabled, on by default, but the integration does nothing
if OW Forms is not active.
What it brings
1. The consent ↔ submission link. For every stored submission, a row is written into a table that belongs to OW Consent — the OW Forms schema is never touched — containing:
- the visitor's pseudonymous token (32 hex, never the email address);
- the id of the most recent ledger entry for that token;
- the granted categories and the compliance profile in force;
- the full fingerprint of the documents published at that moment;
- the timestamp of the consent;
- the state of the form's GDPR checkbox and the exact label it carried.
That last point is the most useful one in practice: you can demonstrate not only that the box was ticked, but what was written next to it.
2. The erasure cascade. A verified erasure request delegates the deletion to the OW Forms routine — which also removes uploaded files — then deletes the link rows. The integration detects whether OW Forms has already hooked its own listener onto the same action, in which case it merely prunes its links.
3. WordPress's privacy tools. An exporter and an eraser are registered, so that Tools → Export / Erase Personal Data covers OW Forms submissions — and so does OW Consent's rights portal, since it walks the same hooks. The export uses the real field labels and adds the consent fields. Every exported value is neutralised against spreadsheet formula injection and truncated.
4. Retention. On the daily cron, links older than retention_form_data_days (default
1095 days, bounds 1 to 3650) are deleted in batches of 500, with a sweep for orphans.
Only OW Consent's rows are touched: retention of the submissions themselves belongs to
OW Forms.
Limits
- No submission event is written into the hash chain. The link row references an existing entry, it does not create one — the chain must not be written from outside.
- A submission cannot be linked if OW Forms is configured not to store submissions: there is no row to attach to.
- No retroactive catch-up: linking starts with the first submission after the update.
- Matching is done on the salted fingerprint of the address computed by OW Forms, with a fallback to the plaintext address that OW Forms also keeps. This is not a zero-disclosure scheme: the address stays in the clear inside OW Forms, because you have to be able to reply to the person.
- The integration never modifies the OW Forms plugin, its tables or its options.
- If OW Forms is active but too old to expose its erasure routine, the eraser explicitly reports that the submissions were not touched.
The floating button
A small persistent button lets anyone reopen the preferences panel at any time. That is the requirement of Article 7(3) — withdrawal must stay as simple as consent — and of CNIL deliberation 2020-091, which asks for a mechanism reachable from every page.
Conditions for it to exist
It requires both settings: floating_button_enabled and banner_enabled. The
preferences panel and the JavaScript API live in the banner's runtime; without it, the
button would be a control that does nothing.
It also bails out in the admin, on a feed, on robots.txt, and if wp_head was not
fired.
Options
| Setting | Values | Default |
|---|---|---|
floating_button_enabled | boolean | true |
floating_button_position | bottom-left, bottom-right, top-left, top-right | bottom-left |
floating_button_style | pill, icon | pill |
floating_button_label | free text | Cookies |
The button has no theme setting of its own: it takes banner_style, so that both
surfaces follow the same palette.
Behaviour and accessibility
It is a real <button>, so it is keyboard accessible. It carries
aria-haspopup="dialog" and an aria-label that is translatable but not configurable
(“Manage my cookie choices”): only the visible label can be set. In icon mode, the label
is visually hidden but still read by screen readers.
It is rendered with the hidden attribute; JavaScript decides to show it. It is visible
in every state except when the banner or the panel is already on screen — including
immediately after a choice made on the current page.
A click opens the preferences panel. If the banner's API does not exist, the button falls
back to the #owc-preferences fragment, re-firing the event by hand if that is already
the current fragment.
z-index 99990, hidden when printing, support for forced-colours mode and
prefers-reduced-motion, offset below the admin bar in the top position.
Shortcodes
The plugin declares two shortcodes, and only two. Neither the banner nor the floating button has one: they show everywhere or nowhere, driven by their settings.
[owc_dsar_form] — the rights portal
[owc_dsar_form]
[owc_dsar_form types="access,erasure,portability" title="My rights" submit_label="Send"]
| Attribute | Default |
|---|---|
types | access,rectification,erasure,portability,restrict,object,optout |
title | “Exercise my personal data rights” |
submit_label | “Send my request” |
The types you ask for are intersected with the ones the server accepts. The withdraw
type is never offered by the form; see The rights portal.
[owc_dnsmpi] — the CCPA opt-out
[owc_dnsmpi]
[owc_dnsmpi label="Do not sell or share my personal information" class="footer-link"]
| Attribute | Default |
|---|---|
label | “Do Not Sell or Share My Personal Information” |
class | owc-dnsmpi |
A click executes the opt-out; see The CCPA opt-out. If you leave
ccpa_inject_footer on, the link is already injected into the footer and this shortcode
is only useful for putting it somewhere else.
Reopening the panel from a menu or a link
There is no shortcode for that, because a class attribute is enough:
<a href="#owc-preferences" class="owc-open-preferences">Manage my cookies</a>
<button type="button" data-owc-open>My preferences</button>
<a href="#owc-dnsmpi" data-owc-dnsmpi="1">Do not sell my information</a>
This is the recommended way to add a “Cookies” entry to your footer menu.
REST API
Every route lives under the owc/v1 namespace, at the usual REST root
(https://example.com/wp-json/owc/v1/…). Thirteen routes in total: twelve registered by
the REST module, plus the vendor list route registered by the TCF module.
Public routes
No authentication. Their security rests on the origin check, a plugin-specific token and the rate limits described below.
| Method | Path | Parameters | Role |
|---|---|---|---|
GET | /nonce | action (default wp_rest) | Returns a fresh token. Response: {nonce, action, header: "X-OWC-Nonce", ttl: 43200}, with no-store and Vary: Cookie |
POST | /consent | event (default save_preferences), source (default banner), categories (required), page_url (default ''), cookie_unreadable (boolean, default false) | Records a choice, sets the cookie and writes the ledger row |
GET | /state | — | The state of the caller only: {given, categories, profile, at}. Never the visitor token, never the IP, never the page URL. no-store + Vary: Cookie |
GET | /tcf/gvl | — | Serves the cached Global Vendor List. ETag, Cache-Control: public, max-age=86400, 304 on a conditional request, 503 + Retry-After: 300 if nothing is cached |
POST | /dsar | type (required), email (required, email format), message (default '', 2000 characters max) | Files a data subject request. Response {ok, mail_sent, message} — never the id |
Accepted values:
event:accept_all,reject_all,save_preferences,withdraw,auto,gpc_opt_outsource:banner,preferences,footer_link,api,autotype(DSAR):access,rectification,erasure,portability,restrict,object,optout,withdrawcategories: an object, 32 entries maximum, scalar values onlyaction(nonce):wp_restonly
Admin routes
All of them require the manage_options capability.
| Method | Path | Parameters | Role |
|---|---|---|---|
GET | /ledger | page (≥1, default 1), per_page (1–200, default 50), visitor_token (32 hex), from, to | Reads the ledger. The filters actually applied are returned in the response |
GET | /ledger/verify | — | Verifies the chain end to end |
GET | /settings | — | The effective settings |
POST | /settings | free-form JSON body | Writes the settings. Response {ok, updated, rejected, settings} |
POST | /scanner/run | — | Starts a sweep |
POST | /scanner/ingest | JSON body {page, findings[]} | Receives the probe's observations |
GET | /scanner/findings | page, per_page (1–200, default 50), filter (category slug or unknown) | Lists the findings |
POST | /policies/generate | type (required), publish (boolean, default false) | Generates a document |
GET | /policies/preview | type (required) | Previews a document |
/scanner/ingest additionally requires a valid wp_rest nonce, in the X-WP-Nonce
header or as a _wpnonce parameter — because sendBeacon cannot set a header.
/settings on write only accepts keys declared as real settings: a filter can inject
synthetic keys that are not. Every value is sanitised individually. No known key gives a
400.
/ledger re-tests the capability inside the handler, as defence in depth against a
permission_callback filtered elsewhere.
The security model for public writes
The starting observation is simple: a nonce cannot live in cacheable HTML. So
/consent and /dsar have a permissive access check, protected by four layers in an
order that matters.
- Same-host origin check. Free, deterministic, consumes no budget.
- A plugin-specific token in the
X-OWC-Nonceheader — that is proof, never a veto. Not usingX-WP-Nonceis deliberate: the WordPress core intercepts that header before any route access check and refuses the whole request if it does not validate it. A stale token served by a cache would therefore kill a write the endpoint would have accepted with no token at all. - Fail closed: neither an origin nor a verified token gives a 403
owc_missing_origin. - Rate limiting last. If it came first, a misconfiguration would consume two tokens per click and end in a 429, which would hide the configuration error behind a rate limit.
Accepted hosts: the ones WordPress declares (site URL, WordPress URL, REST root) with
their www./apex twin, plus the host from the current request's Host header and its
twin. “Same origin” means “the origin matches the host the browser connected to”, not
“matches what WordPress has in the database” — otherwise any site reached through a
preview domain, an alias, a staging name, a mapped multisite domain or a proxy that
rewrites Host would be refused silently, with no way to repair itself. Filter:
owc_allowed_request_hosts.
CORS is left to the WordPress core, deliberately.
/nonce explicitly refuses the JSONP form: without that refusal, a third-party page could
harvest a passing administrator's live token through a simple <script src>, which is not
subject to the origin check.
The rate limits
| Bucket | Budget | Window | Key |
|---|---|---|---|
nonce | 120 | 5 minutes | IP address |
consent | 30 | 5 minutes | IP address |
dsar | 3 | 1 hour | IP address |
dsar_email | 3 | 24 hours | target email address |
dsar_global | 30 | 1 hour | the whole site |
gpc | 30 | 1 hour | IP address |
tcf_gvl | 10 | 1 hour | IP address |
- Fixed window, not sliding: a bucket that receives traffic always expires eventually.
- The stored key is a salted fingerprint: no IP address and no email address in the clear ends up in the options or the object cache.
- IP normalisation: IPv4 kept as is, IPv6 truncated to /64 — the client controls every bit of the interface identifier, so a full key would let it mint a fresh budget on every request.
- Shared identity: behind a CDN or a proxy not declared through
OWC_TRUSTED_PROXY, every visitor arrives with the same address. The budget is then split per visitor, with a wider counter on the shared address. That is a coarser limit: declare your proxy. - Filter
owc_throttle_max( $max, $bucket, $window )— returning0disables the limit. - The limiter fails open if the object cache is unavailable: better not to limit than to stop someone from exercising their rights.
The /consent route in detail
headers_sent()is read first of all. If output has already started, the server cannot set the cookie: the row is written anyway and the response stays 200, withcookie_set: falseand acookiedescriptor (name,value,ttl,path,samesite,secure) that the client sets itself.- Categories the site does not offer are returned in
dropped_categories, not silently ignored. page_urlis validated against the site's hosts, falling back to aReferervalidated the same way, otherwise the empty string. The key is always present, so that the ledger does not go looking for the rawReferer.- If the ledger is enabled and the write is refused, the cookie is cancelled and the
response is a 503
owc_ledger_write_refused. Nothing is stored, no tracker is released. cookie_unreadableis pure telemetry: no validation check is applied to it, precisely so that a diagnostic field can never refuse a consent write. Its only effect is a note in the refusal log.
Typical response: {ok, state, dropped_categories, logged, cookie_set[, cookie]}. The
logged field is null when the ledger is disabled.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
owc_bad_param | 400 | Invalid parameter |
owc_bad_categories | 400 | categories object malformed, too large or non-scalar |
owc_bad_email | 400 | Invalid email address |
owc_bad_dsar | 400 | Malformed DSAR submission (honeypot included) |
owc_dsar_not_attested | 400 | Article 12(6) attestation missing |
owc_no_settings | 400 | No known settings key in the body |
owc_missing_origin | 403 | Neither origin nor token: failing closed |
owc_bad_origin | 403 | The declared origin is not a host of this site — the response lists up to ten accepted hosts |
owc_bad_nonce | 403 | Invalid nonce on /scanner/ingest |
owc_jsonp_forbidden | 403 | JSONP form refused on /nonce |
owc_forbidden | 401/403 | Insufficient capability |
owc_dsar_disabled | 404 | The rights portal is disabled server-side |
owc_no_template | 404 | No template for this type and this profile |
owc_rate_limited | 429 | Rate limit reached |
owc_consent_failed | 500 | Failed to apply the consent |
owc_dsar_store_failed | 500 | Failed to write the request |
owc_gen_failed | varies | Document generation refused (missing fields, language lock) |
owc_ledger_write_refused | 503 | The ledger refused to write — nothing is recorded, nothing is unblocked |
The refusal log
The last twenty write refusals are kept and shown on the dashboard — only if the log is not empty. Two kinds are distinguished there:
- an entry with an HTTP status is a genuine refusal: nothing was stored and the visitor saw an error;
- an entry with a status of 0 is a warning: the choice was recorded.
The panel shows the host declared by the origin and the request's Host header side by
side. That is exactly what differs when your visitors browse on one host while WordPress
is configured with another — the most common cause of consent that “does not save”.
Limits of the API
/consentand/dsaraccept anonymous writes by design; the protection is the origin plus the rate limit, not a nonce.- The rate limiter fails open without an object cache.
- The consent cookie is host-only: a cookie set for the apex cannot be read from a
www.page, and no CORS header can fix that. The remedy is a same-origin write URL. /scanner/ingestonly acceptsmanage_options: the probe cannot come from a visitor./settingshas no nonce of its own: it ismanage_optionsplus the core's cookie-based nonce check.
Settings reference
Every setting lives in a single option, owc_settings, autoloaded since it is read on
every front-end page.
Reading and writing in PHP
$settings = OWC_Core::settings(); // effective settings (defaults + saved + filters)
$profile = OWC_Core::setting( 'compliance_profile' );
$raw = OWC_Core::stored_setting( 'compliance_profile' ); // ignores the per-visitor override
OWC_Core::update_settings( array(
'consent_renewal_months' => 6,
'compliance_strict' => true,
) );
update_settings() merges only the submitted keys over what already exists, never the
full set of defaults. That is what lets the option row stay empty until you have
customised something, and therefore lets the texts follow the site's language.
The result of settings() is memoised, with a key based on the whole set of callbacks on
the owc_settings filter: a module that registers its filter after the first read
invalidates the memo instead of being ignored. The memo is cleared on every write of the
option.
Default values
[
// Banner
'banner_enabled' => true,
'banner_position' => 'bottom-bar', // bottom-bar | bottom-card | center-modal | top-bar
'banner_style' => 'auto', // auto | light | dark
'banner_accept_all' => true,
'banner_reject_all' => true,
'banner_preferences' => true,
'banner_close_x' => false, // cross = implicit refusal
'banner_show_logo' => true,
'text_title' => 'We use cookies',
'text_message' => 'We use cookies to personalise content, measure audience, '
. 'and provide social media features. You can accept or reject, '
. 'and change your choice at any time.',
'text_accept_all' => 'Accept all',
'text_reject_all' => 'Reject all',
'text_preferences' => 'Customize',
'text_save' => 'Save my choices',
// Floating button
'floating_button_enabled' => true,
'floating_button_position' => 'bottom-left',
'floating_button_style' => 'pill',
'floating_button_label' => 'Cookies',
// Consent
'consent_renewal_months' => 12, // 0 is turned into 13, not into “never”
'consent_policy_hash_check' => true,
// Compliance
'compliance_profile' => 'gdpr',
'compliance_strict' => true,
'geo_enabled' => false,
'geo_default_profile' => 'gdpr',
'geo_mmdb_enabled' => false,
// IAB TCF v2.2
'tcf_enabled' => false,
'tcf_cmp_id' => 0,
'tcf_publisher_country' => 'FR',
'tcf_publisher_purposes_li' => [],
'tcf_special_features' => [],
// CCPA / GPC
'ccpa_inject_footer' => true,
'gpc_honor' => true,
// Google Consent Mode v2
'gcm_enabled' => true,
'gcm_ads_data_redaction' => true,
'gcm_url_passthrough' => true,
// Categories
'cat_necessary_available' => true,
'cat_functional_available' => true,
'cat_analytics_available' => true,
'cat_marketing_available' => true,
'cat_preferences_available' => true,
'cat_social_available' => true,
'cat_necessary_label' => 'Necessary',
'cat_necessary_desc' => 'Strictly required for the site to function …',
'cat_functional_label' => 'Functional',
'cat_functional_desc' => 'Enhance the experience (chat, embedded videos, maps) …',
'cat_analytics_label' => 'Statistics',
'cat_analytics_desc' => 'Help us understand how you use the site (anonymously) …',
'cat_marketing_label' => 'Marketing',
'cat_marketing_desc' => 'Enable us to show you ads and content tailored …',
'cat_preferences_label' => 'Preferences',
'cat_preferences_desc' => 'Remember your interface choices (layout, saved filters).',
'cat_social_label' => 'Social & embeds',
'cat_social_desc' => 'Allow embedded social content (YouTube, Instagram, X) to load.',
// Ledger
'ledger_enabled' => true,
'ledger_retention_days' => 1825, // 5 years; 0 = unlimited
'ledger_hash_algo' => 'sha256',
// Scanner
'scanner_enabled' => false,
'scanner_frequency' => 'weekly',
'scanner_alert_email' => '',
'scanner_max_urls' => 25,
'scanner_timeout' => 8,
'scanner_probe_mode' => 'admins',
// Rights portal
'dsar_enabled' => true,
'dsar_email' => '',
'dsar_response_days' => 30,
'dsar_notify_email' => '',
'dsar_token_ttl_days' => 7,
// Blocking
'blocker_unknown_script_policy' => 'allow',
'blocker_unknown_iframe_policy' => 'block',
'blocker_allowlist' => '', // one host per line
'blocker_block_resource_hints' => true,
// Integration and lifecycle
'forms_integration_enabled' => true,
'auto_footer_menu_inject' => false,
'delete_data_on_uninstall' => false,
// Legal identity
'legal_company_name' => '',
'legal_company_legal_form' => '',
'legal_company_capital' => '',
'legal_company_address' => '',
'legal_company_email' => '',
'legal_company_phone' => '',
'legal_country' => 'FR',
'legal_company_reg_number' => '',
'legal_company_reg_place' => '',
'legal_company_vat' => '',
'legal_publication_director' => '',
'legal_eu_representative' => '',
'legal_host_name' => '',
'legal_host_address' => '',
'legal_host_phone' => '',
'legal_host_url' => '',
'legal_profession_body' => '',
'legal_profession_title' => '',
'legal_profession_state' => '',
'legal_profession_rules' => '',
'legal_mediator_name' => '',
'legal_mediator_address' => '',
'legal_mediator_url' => '',
'legal_dpo_name' => '',
'legal_dpo_email' => '',
'legal_dpa_authority' => '', // empty on purpose: derived from country + profile
'legal_transfer_countries' => '',
'legal_third_parties_note' => '',
'legal_automated_decisions' => false,
'legal_automated_decisions_desc' => '',
'legal_minor_age' => 15,
'legal_data_provision_note' => '',
'legal_currency' => 'EUR',
'legal_tax_label' => 'TTC',
'legal_business_type' => 'auto',
'retention_form_data_days' => 1095,
'retention_dsar_days' => 1095,
'policy_disclaimer' => true,
// Links
'link_privacy_policy' => '',
'link_cookie_policy' => '',
'link_terms' => '',
'link_legal_notice' => '',
]
Closed lists
A value outside the list falls back to the default.
| Setting | Accepted values |
|---|---|
banner_position | bottom-bar, bottom-card, center-modal, top-bar |
banner_style | auto, light, dark |
floating_button_position | bottom-left, bottom-right, top-left, top-right |
floating_button_style | icon, pill |
compliance_profile, geo_default_profile | gdpr, ccpa, lgpd, popia, pipl, dpdp, quebec, uk_pecr, ch_nfadp, au, us_generic |
ledger_hash_algo | sha256, sha3-256 (intersected with what PHP supports) |
scanner_frequency | hourly, twicedaily, daily, weekly |
scanner_probe_mode | admins, off |
legal_business_type | auto, vitrine, rental, ecommerce, services, saas, content |
blocker_unknown_script_policy, blocker_unknown_iframe_policy | allow, block |
Integer bounds
| Setting | Bounds |
|---|---|
consent_renewal_months | 0 to 13 (0 is treated as 13) |
dsar_response_days | 1 to 30 |
dsar_token_ttl_days | 1 to 90 |
ledger_retention_days | 0 to 3650 (0 = unlimited) |
scanner_max_urls | 1 to 500 |
scanner_timeout | 1 to 60 |
legal_minor_age | 13 to 18 |
retention_form_data_days | 1 to 3650 |
retention_dsar_days | 1 to 3650 |
tcf_cmp_id | 0 to 4095 |
Sanitisation
text_messageis the only rich-HTML field; it accepts the HTML allowed in a WordPress post, and the banner ultimately renders only<a href target rel>.- Multi-line fields:
legal_company_address,legal_eu_representative,legal_host_address,legal_mediator_address,legal_third_parties_note,legal_automated_decisions_desc,legal_data_provision_note,blocker_allowlist, and any key ending in_desc. - URLs: any key prefixed
link_or suffixed_url, pluslegal_profession_rules. - Email addresses: any key containing
email. - Everything else: plain text.
- A non-scalar value submitted for a scalar setting is ignored, and the key is not written.
Where each thing is set
| Tab | Settings |
|---|---|
| Banner | banner_*, text_*, floating_button_*, consent_renewal_months, consent_policy_hash_check |
| Compliance | compliance_profile, compliance_strict, gcm_*, blocker_*, ccpa_inject_footer, gpc_honor, tcf_*, geo_*, auto_footer_menu_inject, forms_integration_enabled, delete_data_on_uninstall, link_* |
| Legal identity | legal_*, retention_form_data_days, retention_dsar_days, policy_disclaimer |
| Categories | cat_*_available, cat_*_label, cat_*_desc |
| Audit ledger | ledger_enabled, ledger_retention_days (the algorithm is displayed, not editable) |
| Scanner | scanner_* |
| DSAR requests | dsar_* |
Tables
| Table | Contents |
|---|---|
{prefix}owc_ledger | Chained consent ledger |
{prefix}owc_dsar | Data subject requests |
{prefix}owc_scanner | Scanner findings |
{prefix}owc_scripts | Signature catalogue for the blocking |
{prefix}owc_form_links | Consent ↔ OW Forms submission links |
A PHP helper, owc_table( 'ledger' | 'dsar' | 'scanner' | 'scripts' ), recomputes the name
from the current prefix: use it inside a switch_to_blog(), since the constants are
frozen for the duration of the request.
Other options and transients
Options: owc_settings, owc_version, owc_pending_upgrade, owc_upgrade_lock,
owc_ledger_head, owc_ledger_trim, owc_ledger_key_source, owc_scanner_secret,
owc_scanner_lock, owc_scanner_queue, owc_scanner_status, owc_scanner_alerted,
owc_scanner_probe_status, owc_scanner_manual, owc_refusal_log, owc_tcf_status,
owc_tcf_gvl_version, owc_forms_db_version, plus the migration flags
(owc_catalog_sanitized_v2, owc_match_target_migrated_v1, owc_ledger_hmac_migrated_v1,
owc_inline_sig_labels_cleaned_v1, owc_scanner_secret_rotated_v1).
Transients: owc_catalog_<version>, owc_invalid_patterns, owc_gvl_cache,
owc_gvl_stub, owc_gvl_etag, owc_gvl_retry, owc_mmdb_<fingerprint>,
owc_policies_hash, owc_site_analysis, owc_scanner_last_run,
owc_scanner_run_result, owc_scanner_pruned, owc_scanner_fail_alerted,
owc_ledger_verify_result, owc_ledger_write_error, owc_policy_gen_result,
owc_dsar_sla_alert, owc_dsar_admin_result, and the rate-limiting counters.
Constants recognised in wp-config.php
| Constant | Effect |
|---|---|
OWC_LEDGER_KEY | The ledger's HMAC key, and the derived key of the regional detection cookie |
OWC_TRUSTED_PROXY / OWC_TRUSTED_PROXIES | CIDR ranges of the trusted proxies; without them, client IP headers are ignored |
OWC_GEO_TRUSTED_HEADERS | cloudflare, cloudfront, proxy or all |
OWC_BEHIND_CLOUDFLARE | Shortcut for the Cloudflare country header |
OWC_BEHIND_CLOUDFRONT | Shortcut for the CloudFront country header |
Developer hooks
Filters
| Filter | Role |
|---|---|
owc_settings | Effective settings. The answer must depend on something that is stable for the whole request |
owc_banner_texts | Banner texts |
owc_bootstrap_config | Static configuration printed in <head> — never any per-visitor data |
owc_scripts_catalog | Catalogue rows before validation |
owc_blocker_allowlist | Hosts that are never blocked |
owc_scanner_urls | URLs to sweep (re-restricted to the site's host afterwards) |
owc_throttle_max | Budget of a rate limit; 0 disables it |
owc_allowed_request_hosts | Hosts accepted for a public write |
owc_allow_headerless_write | Lifts the fail-closed rule on writes with no origin (default false) |
owc_geo_mmdb_path | Path to the MaxMind database |
owc_tcf_purpose_map | Category → TCF purpose mapping |
owc_tcf_stub_config | Static configuration of the TCF stub |
owc_policy_template | Raw body of a template — disables the language lock |
owc_policy_vars | A document's variables |
owc_policy_html | A document's final HTML |
owc_dsar_types | Request types offered and accepted |
owc_dsar_form_notice | The information notice under the form |
owc_dsar_export_bundle | Contents of the portability bundle |
owc_dsar_show_fulfilment_panel | Whether the fulfilment panel is displayed |
Actions
| Action | Signature |
|---|---|
owc_consent_updated | ( array $categories, string $event, string $source ) |
owc_consent_cookie_not_sent | ( array $cookie, string $event ) |
owc_ledger_lock_failed | ( string $event, string $token ) |
owc_ledger_write_failed | ( $error, string $event, string $token ) |
owc_ledger_anchor | ( array $anchor ) |
owc_ledger_pruned | ( int $deleted, int $days ) |
owc_catalog_updated | — |
owc_gpc_honored | ( array $categories ) |
owc_tcf_inactive | ( string $reason ) |
owc_dsar_submitted | ( string $type, string $email, string $message ) |
owc_dsar_verified | ( int $id, array $row ) |
owc_dsar_fulfilled | ( int $id, string $what, $trace ) |
owc_dsar_mail_failed | ( int $id, string $email, string $kind ) |
Crons: owc_daily_maintenance, owc_ledger_retention, owc_scanner_run,
owc_scanner_run_batch, owc_tcf_refresh_gvl, owc_run_upgrade.
Internationalisation
The text domain is ow-consent, the path /languages. The source language is
English. The package ships the ow-consent.pot template and a complete French
translation.
A reminder of the mechanism described above: as long as a label or a banner text is not customised in the settings, it follows the site's language. As soon as you enter your own value, it is served as is, whatever the visitor's language.
The bodies of the legal documents do not go through that mechanism: their language follows the jurisdiction, never the administrator's locale.
Troubleshooting
The banner does not appear
Work down the list in order:
- Is
banner_enabledon? The badge at the top of the admin screen says so. - Does your theme call
wp_head()andwp_footer()? Without them, the banner refuses to print rather than producing inert markup. - Has the visitor already made a choice? The banner only shows on
<html data-owc="none">. Test in a private window or withwindow.OWCBanner.reset(). - Does the visitor have JavaScript? Without it, the banner stays hidden —
deliberately — and the
<noscript>block takes over. - Is a page cache serving a version from before activation? Flush it.
The banner comes back on every page even though the choice was recorded
Three causes, in order of frequency.
- The host being browsed differs from the host configured in WordPress (
www.versus apex, an alias, a staging domain). The browser then refuses to store the cookie while WordPress answers 200. Open the dashboard: the refusal log shows the origin and theHostheader side by side. The remedy is to serve the site on a single canonical host. consent_policy_hash_checkis on and your documents have changed — that is the intended behaviour, the visitor is asked again after a policy change.- You are on a version older than 1.4.3. The cookie was double-encoded then and the browser could not read it back, while the server read it perfectly well. Update: cookies written before the fix stay readable.
“Your choice could not be saved” — refused error or 403
Look at data-owc-code on the message element, or at the dashboard's refusal log.
owc_bad_origin: the declared origin is not a recognised host. The response lists the accepted hosts. If your site is legitimately reached through several names, add them with theowc_allowed_request_hostsfilter.owc_missing_origin: neither an origin nor a token. That is typically a privacy extension stripping the headers, or a proxy rewriting them.owc_bad_nonce: this only concerns the scanner probe, not the consent write — the nonce is never a veto on/consent.
ratelimit error — 429
The limits are deliberately low on public writes. Two causes:
- An undeclared CDN or proxy: all your visitors arrive with the same address and
share the same budget. Declare it with
OWC_TRUSTED_PROXYinwp-config.php. - An automated test sending more than 30 writes in 5 minutes from the same address.
A 503 owc_ledger_write_refused
The ledger refused to write, so nothing was recorded and no tracker was released. That is the intended behaviour: consent you cannot prove must not be claimed.
- Check that the
{prefix}owc_ledgertable exists. The Audit ledger tab shows a readable “not installed yet” message rather than a blank screen. - Check the MySQL user's write permissions.
- As a last resort, disable
ledger_enabledtemporarily to restore service — knowing that you lose the proof for as long as it is off.
Trackers still load before consent
- Is
compliance_stricton? Without it, only the scripts queued by WordPress are handled. - Is the tracker in the catalogue? An unknown third-party script is allowed by default. Run a sweep and classify the finding: that writes the blocking rule.
- Is it injected by first-party JavaScript? The dynamic guard only covers twenty hosts. Add a catalogue rule on the tracker's domain.
- Is it a cookie set through a
Set-Cookieheader? No client-side blocking can hold it back. It has to be handled at the source, server-side. - Switch
blocker_unknown_script_policytoblock— then test the site carefully, because that setting blocks every unrecognised third party.
A legitimate integration is blocked
Add its host to blocker_allowlist, one per line, or through the
owc_blocker_allowlist filter. Also check blocker_unknown_iframe_policy: unknown
iframes are blocked by default, which is the most common case.
If the integration should stay conditional on consent, classify it in the right category instead: the visual placeholder carries an “Accept …” button that unblocks it in one click.
The scanner finds nothing
- Is it enabled? It is off by default.
- Is WP-Cron working? With
DISABLE_WP_CRONand no system cron, the scheduled sweep never fires. Run one by hand to check. - Is the HTTP loopback possible? Staging HTTP authentication or a firewall blocks the sweep. The coverage panel shows the first error.
- The server-side sweep executes no JavaScript. Visit a page of the site as a logged-in administrator so that the probe reports what a tag manager injects.
In every case, read the “what this sweep actually covers” panel before drawing a conclusion: it tells “found nothing” apart from “read nothing”.
Generating a document is refused
Two possible causes, and the message says which:
- Required fields are empty — the list of the missing keys is shown. Complete the Legal identity tab.
- Language lock — you are asking for a legal notice or terms for a non-French-speaking
jurisdiction. The button is hidden in that case. The only way through is to supply your
own body via the
owc_policy_templatefilter.
The DSAR verification email does not arrive
It is almost always deliverability, not the plugin.
- The API response carries
mail_sent: falsewhen the send failed, and the form then shows a dedicated message pointing to the contact address. - Install an SMTP plugin. PHP's
mail()function is rejected by most receiving servers. - Check your domain's SPF, DKIM and DMARC. The plugin never rewrites the sending
address — that is what would break SPF — it only sets a
Reply-To. - Check that
dsar_emailis a valid address.
Until the email is received and confirmed, the request stays pending and the legal
clock does not start.
The TCF module does not activate
Open the Compliance tab: an admin notice names the cause.
missing_cmp_id:tcf_cmp_idis 0. You have to obtain a CMP ID from IAB Europe; the plugin does not supply one and refuses to emit a string with an id of 0.- CMP ID above 4095: the value does not fit in the string's 12-bit field and would designate another CMP.
no_gvl: the Global Vendor List has not been downloaded yet. It arrives through a daily cron; check that WP-Cron is running and that your server can reachvendor-list.consensu.orgover outbound HTTPS.
Every visitor gets the configured profile, despite regional detection
- Is
geo_enabledon? It is off by default. - Have you declared your proxy? Without
OWC_BEHIND_CLOUDFLARE,OWC_BEHIND_CLOUDFRONTorOWC_GEO_TRUSTED_HEADERSinwp-config.php, country headers are ignored, because they are forgeable. - Is the MaxMind file in place? The plugin does not download it and offers no upload screen.
- It may be normal: a visitor's first view served from the cache uses the configured profile for as long as the 24-hour cookie does not exist. The dashboard shows a diagnostic block with the country detected, the profile applied and the source.
The admin reports that the ledger key lives in the database
That is the notice_ledger_key notice. It means AUTH_KEY and AUTH_SALT are not in
wp-config.php — so WordPress stores them in the database — and that OWC_LEDGER_KEY is
not defined either. The chain is still being built, but anyone with database access can
re-sign it: the tamper-evidence claim no longer holds.
The remedy is to add OWC_LEDGER_KEY (and, while you are there, AUTH_KEY and
AUTH_SALT) to wp-config.php. Verification then tells a key rotation apart from a
rewrite, so that change does not invalidate your history.
The plugin does nothing at all, with a red notice
Your WordPress is older than 6.2. The plugin refuses to boot, and the notice says explicitly that nothing is being blocked and no consent is being recorded. Update WordPress, or deactivate the plugin and take the banner off your pages in the meantime.
Catalogue rules “cannot fire”
An admin notice flags them, and the Tracker catalogue tab has a dedicated filter. Four possible reasons: an empty pattern or one made only of invisible characters, a regular expression that does not compile or that backtracks catastrophically, a pattern too short and without a dot to target a URL, or a category that no longer exists on the site. Fix or delete those rows: they give a false impression of coverage.
FAQ
Does the plugin work behind a page cache? Yes, and its whole architecture is built around that constraint. The HTML produced is identical for every visitor; consent is read in the browser and applied before the first paint. No nonce is printed into cacheable HTML. Only genuinely personal responses are marked non-cacheable.
Does it replace a paid CMP? On most sites it covers the same ground: banner, blocking, audit log, rights requests, geographic routing and generated documents. Two things it does not do: it is not a CMP registered with IAB Europe, and it provides no legal advice and no reviewed documents — the generator produces drafts you have to get validated.
Is the ledger tamper-proof?
It is tamper-evident, under one condition. Every row is signed with HMAC over the
previous row's fingerprint: a modified or deleted row breaks the chain and verification
says where. The guarantee rests on the signing key living outside the database. That is
the case if AUTH_KEY and AUTH_SALT are in wp-config.php, or if you define
OWC_LEDGER_KEY. Otherwise WordPress stores the salts in the database and an attacker who
gets in could re-sign the chain — the plugin detects that situation and warns you.
Does the scanner slow the site down? No. It is off by default, and once enabled it runs on WP-Cron at the frequency you choose, fetching a sample of your own pages server-side. It never runs while a visitor is browsing.
Does the scanner find the cookies?
Partly, and that is important to understand. The server-side sweep reads the Set-Cookie
headers of your pages: it therefore finds the cookies set by the server, including
HttpOnly ones. It runs no JavaScript, so cookies written by scripts in the browser are
invisible to it; those are collected by a probe that only runs for a logged-in
administrator. Treat the result as an inventory of what has been seen, not as an
exhaustive list — both screens say so instead of letting you assume otherwise.
How is a rights request fulfilled? The person submits the form and receives a verification email. Confirming the request on the linked page starts the Article 12(3) clock and opens a native WordPress request. From the DSAR screen you download a JSON export produced by every exporter registered on the site, you run every eraser, and you close the request with a written answer that stays attached to it. What the export covers therefore depends on the plugins installed; those that register none of these hooks have to be handled by hand.
Can I use the TCF module for AdSense or Ad Manager? Only with your own CMP ID issued by IAB Europe, and even then with a caveat. Without a CMP ID, the module does not load at all. With one, it emits a correctly encoded TC string and the panel shows every purpose and every special feature that string can claim — but TCF policy requires a registered CMP to offer choices at purpose level and at vendor level. Here the purpose switches follow the categories and there is no vendor choice: it is not a registered CMP, and vendors are entitled to reject its signal. If ad revenue under TCF matters to your site, use a certified CMP.
What language are the documents generated in? The privacy policy and the cookie policy exist for all eleven profiles, in the language of the jurisdiction: English, French for France, Belgium, Luxembourg and Québec, Portuguese for Brazil. The legal notice and the terms exist in French only; for any other jurisdiction, the generator refuses to produce them rather than publish an unsuitable document.
What can I change on the banner?
Four positions, a light, dark or automatic theme, an optional logo, the labels, and a
floating button to reopen it. Any element carrying the owc-open-preferences class
reopens the panel. “Reject all” is rendered with the same prominence as “Accept all”. The
close cross is off by default; when it is on, it records a full refusal, never a silent
dismissal. The panel is keyboard-usable, its switches carry a visible state, and the modal
traps then restores focus. The texts are translatable and can be replaced through the
owc_banner_texts filter.
What exactly does the automatic blocking block? Third-party scripts, inline tracking snippets, iframes, measurement pixels, analytics and marketing stylesheets, and third-party media sources are rewritten before consent and released afterwards, in document order. Resource hints pointing at a catalogued third party are removed rather than deferred, since a hint opens a connection and has nothing to restore. Stylesheets and fonts served by a host classified as functional are left intact. The lazy-loading attributes of cache plugins are neutralised so that a loader cannot restore a blocked URL. The plugin ships 175 signatures, editable from the Tracker catalogue screen and extensible through a filter.
What goes into the ledger? Every consent action — acceptance, refusal, partial save, GPC opt-out, renewal, withdrawal — is appended to the chained table. A row holds a pseudonymised IP, a user-agent fingerprint, the page URL, the compliance profile, a fingerprint of the documents in force, a fingerprint of the banner actually displayed and the plugin version — the pieces that make it possible to reconstruct what the visitor saw.
What protections are there on the rights form? A honeypot, a minimum fill-in time, a mandatory attestation checked server-side, three distinct rate limits, a single-use token stored only as a fingerprint, and a confirmation that requires an explicit action on the page — so that an email link scanner cannot confirm an identity on the person's behalf. A request whose author never confirmed their identity cannot be exported, erased or recorded as fulfilled, and that check is enforced server-side, not just by hiding buttons.
Which Google Consent Mode v2 signals are emitted?
All seven: ad_storage, ad_user_data, ad_personalization, analytics_storage,
functionality_storage, personalization_storage and security_storage. The six
categories are mapped to them from a single source of truth, so the banner, the blocking
and the bootstrap cannot drift apart.
How are the CCPA opt-out and GPC handled? A “Do Not Sell or Share” control is injected into the footer or placed with a shortcode. The first click records the opt-out, as the Californian regulations require, rather than opening a panel. The Global Privacy Control signal is treated as a binding opt-out under the US profiles, once per browsing session, and the visitor is told if it replaced a choice they had recorded. Under the GDPR family of profiles, GPC is treated as a hint: the optional categories are pre-refused in the interface, the banner stays on screen, and nothing is recorded — because consent there has to be a positive act.
Which cookies does the plugin set itself?
They are all internal cookies, none of them is used for tracking, and they are declared in
the generated cookie policy: owc_consent (the choices, the pseudonymous browser token
and the profile; duration following the renewal setting, 12 months by default, capped at
13), owc_geo (country and profile detected, 24 hours, with no IP address at all),
owc_gpc (session marker), owc_gpc_notice (5 minutes), and euconsent-v2 (the TCF
string, only if the module is enabled and after the visitor has acted).
Does it work on multisite? Yes. Every site on the network has its own tables and its own settings. A network activation walks every site only if the network has at most 200; beyond that, each site is provisioned on its first request.
And if I delete the plugin? The ledger, the scanner findings, the catalogue, the OW Forms links, the options and the settings survive by default. Two things always go, whatever your setting: the six scheduled tasks, and the rights request table — it is the only one holding directly identifying personal data about other people, and once the plugin has gone nothing bounds its retention and no screen lets you answer it. Export your requests before deleting the plugin. For a full clean-up of the rest, enable “Delete all data on uninstall” before deleting.
Where do I find support?
- Support portal: https://optionweb.dev/en/addons/support/
- Email: support@optionweb.dev
- Documentation: https://optionweb.dev/en/addons/ow-consent/