Someone from legal asks whether the chat widget sets cookies before consent.
You check: the launcher only appears in the corner, nobody has typed anything,
surely not. Then you open Application → Cookies on a fresh incognito load and
find crisp-client/session/... sitting there, set within a second of the page
loading, before any interaction at all.
This is normal for every hosted chat widget, and it is a problem in every jurisdiction with an opt-in cookie regime.
What the law actually requires
Under the ePrivacy Directive (and the UK's PECR), storing or reading information on a user's device requires consent unless it is *strictly necessary for a service the user explicitly requested*. GDPR then governs what you do with the resulting personal data.
The exemption is narrower than teams hope:
- A session cookie that keeps a user logged in: strictly necessary.
- A cookie that remembers a shopping basket: strictly necessary.
- A chat widget that loads on every page, sets a persistent identifier, and tracks a visitor across sessions in case they might want support: not strictly necessary. The user did not request a support session by loading your pricing page.
A defensible argument exists for loading chat after a user clicks "chat with us", at that point they did request it. That is the on-demand pattern at the end of this document, and it is both the most compliant and the fastest option.
Why hiding the launcher is not a gate
// DON'T: the cookies are already set
<Script src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
{consented ? null : <style>{`.crisp-client { display: none }`}</style>}
The cookie is written when the script executes. CSS applied afterwards hides a
UI; it does not un-write storage, close the websocket or forget the identifier.
The same applies to $crisp.push(["do", "chat:hide"]): it is a visibility
command, not a privacy control.
The gate has to be on whether the script runs at all.
The gate
Keep the decision in one module so the policy is one edit, and so any cookie banner can drive it:
// src/lib/support/consent.ts
export type ConsentMode = "required" | "implied";
export const CONSENT_MODE: ConsentMode = "required";
const STORAGE_KEY = "support-consent";
const EVENT = "support-consent-change";
export function hasSupportConsent(): boolean {
if (CONSENT_MODE === "implied") return true;
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "granted";
} catch {
return false; // storage blocked: no record of consent means no consent
}
}
export function grantSupportConsent(): void {
try {
window.localStorage.setItem(STORAGE_KEY, "granted");
} catch {
// ignore: the widget loads for this page view and asks again next time
}
window.dispatchEvent(new CustomEvent(EVENT, { detail: true }));
}
export function onSupportConsentChange(listener: (granted: boolean) => void): () => void {
const handler = () => listener(hasSupportConsent());
window.addEventListener(EVENT, handler);
window.addEventListener("storage", handler); // another tab accepted
return () => {
window.removeEventListener(EVENT, handler);
window.removeEventListener("storage", handler);
};
}
Then the widget renders nothing at all until consent exists:
"use client";
export function CrispWidget() {
const [consented, setConsented] = useState(false);
// Read after mount: localStorage does not exist on the server, and reading it
// during render would produce different markup on each side and a hydration
// error that React papers over in production.
useEffect(() => {
setConsented(hasSupportConsent());
return onSupportConsentChange(setConsented);
}, []);
if (!consented) return null;
return <Script id="crisp-widget" src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
}
Wire your banner's accept button to grantSupportConsent(). Because the widget
subscribes to the change event, the chat appears the moment someone accepts:
no reload needed in that direction.
Withdrawal has to be real, and it needs a reload
Consent must be as easy to withdraw as to give. The awkward truth is that Crisp
(like most chat widgets) has no teardown API. Once the script has run it has
opened a websocket, mounted an iframe and written its cookies, and there is no
unload() to call.
So withdrawal reloads:
export function revokeSupportConsent(): void {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// nothing to remove
}
window.dispatchEvent(new CustomEvent(EVENT, { detail: false }));
window.location.reload(); // the only honest way to stop a script already running
}
If you also need to delete the cookies the widget set, do it before the reload:
enumerate document.cookie, and expire anything prefixed crisp-. Some are set
on the vendor's own domain inside the iframe and are not yours to remove: say
so in your privacy policy rather than pretending otherwise.
Deciding your default
CONSENT_MODE is a policy decision, not a technical one, and it belongs to
whoever owns the privacy policy:
"required": the default here. Correct for the EU, the UK, and anywhere else with an opt-in regime. Nothing loads until someone accepts."implied": the widget loads at idle for everyone. Only defensible where your users are outside those jurisdictions and your privacy policy covers it.
Do not implement "load for signed-in users only" as a consent workaround. Being signed in is not consent to third-party tracking cookies; it is consent to your service.
The pattern that avoids the question entirely
Load on intent. Render your own button; inject the script on first click:
{wanted ? (
<Script
id="crisp-widget"
src="https://client.crisp.chat/l.js"
strategy="lazyOnload"
onLoad={() => window.$crisp?.push(["do", "chat:open"])}
/>
) : null}
Now the user has explicitly requested a support session before anything is stored, which is the clearest form of the strictly-necessary argument. It is also the fastest option for the majority of visitors who never click, and it removes the widget from your cookie banner's "marketing" category entirely.
Whichever route you take, keep two things in sync: the cookie banner's description of what chat does, and the reality of when the script runs. The mismatch between those two is what an audit actually finds.
Verifying
- Fresh incognito window, decline consent, load several pages. Application →
Cookies must contain no
crisp-*entries, and the Network tab must show no request toclient.crisp.chat. - Accept. The widget appears without a reload.
- Withdraw. The page reloads and, on the next load, no cookies and no request.
- In a second tab, accept: the first tab should react to the
storageevent. - Block
localStorage(Safari private mode, or "block all cookies") and confirm the widget stays off rather than throwing.