Your support team gets a message: "Hi, it's Dana from Acme, I've lost access to
my account, can you reset the password on my email?" The chat shows
dana@acme.com in the sidebar, the plan says Enterprise, and the conversation
looks exactly like every other conversation with a customer.
Nobody verified any of it. The visitor opened DevTools and typed:
$crisp.push(["set", "user:email", ["dana@acme.com"]]);
That is all it takes. The email in a chat widget's sidebar is, by default, a string the browser asked the widget to display.
Why the obvious implementation is the vulnerable one
// DON'T
"use client";
export function SupportIdentity() {
const { user } = useSession(); // client-side session hook
useEffect(() => {
if (user) {
window.$crisp.push(["set", "user:email", [user.email]]);
window.$crisp.push(["set", "user:nickname", [user.name]]);
}
}, [user]);
return null;
}
It looks like it identifies the signed-in user, and for honest users it does. But the widget cannot tell the difference between this code running and a person typing the same line into the console. Anything an operator does on the strength of that label (resetting a password, changing a billing address, sharing an invoice, describing account activity) is done on an unverified claim.
The worse version is reading the address from somewhere the user controls:
// MUCH WORSE
const email = searchParams.get("email") ?? localStorage.getItem("last_email");
window.$crisp.push(["set", "user:email", [email]]);
Now impersonation does not even require DevTools; it requires a URL.
The fix: sign the email on the server
Crisp supports HMAC email verification. You sign the address with a secret only your server has, send the signature alongside it, and Crisp marks the conversation verified in the inbox.
Turn it on in Settings → Website Settings → Chatbox & Email Security → Email
verification, copy the secret into CRISP_IDENTITY_SECRET, and sign
server-side:
// src/lib/support/identity.ts: server-only, never imported by a client component
import "server-only";
import { createHmac } from "node:crypto";
import { getSessionUser } from "@/lib/auth/session";
export function signIdentity(email: string): string | null {
const secret = process.env.CRISP_IDENTITY_SECRET;
if (!secret) return null;
return createHmac("sha256", secret).update(email).digest("hex");
}
export interface SupportIdentity {
email: string | null;
emailSignature: string | null;
nickname: string | null;
}
export function supportIdentity(user: {
email: string | null;
name: string | null;
}): SupportIdentity {
return {
email: user.email,
emailSignature: user.email ? signIdentity(user.email) : null,
nickname: user.name,
};
}
export async function currentSupportIdentity(): Promise<SupportIdentity | null> {
const user = await getSessionUser();
if (!user) return null;
return supportIdentity(user);
}
Note the nulls. getSessionUser() returns email: string | null and
name: string | null, because an account created with a phone number (or
through an OAuth provider that withheld the address) has neither. Carrying the
null through to SupportIdentity is what stops a signed-in-but-address-less
account from being labelled with an empty string an operator will read as an
address.
Resolve the identity where the session already lives (a server component) and pass it down as props:
// src/app/layout.tsx
import { CrispWidget } from "@/lib/support/crisp-widget";
import { currentSupportIdentity } from "@/lib/support/identity";
export default async function RootLayout({ children }: LayoutProps<"/">) {
return (
<>
{children}
<CrispWidget identity={await currentSupportIdentity()} />
</>
);
}
Nothing here names an auth provider. @/lib/support/identity reads the session
through @/lib/auth/session, which every auth battery implements identically,
so swapping Better Auth for Clerk or Supabase changes no file under
src/lib/support/.
And push what you actually have, one field at a time:
export function setUser(user: { email?: string; emailSignature?: string }) {
if (!user.email) return;
window.$crisp?.push(
user.emailSignature
? ["set", "user:email", [user.email, user.emailSignature]]
: ["set", "user:email", [user.email]],
);
}
The signature is not a secret: it is derived from one, and it is only valid for
that one address. Shipping it to the browser is the design. What must never
reach the browser is CRISP_IDENTITY_SECRET itself, which is why it has no
NEXT_PUBLIC_ prefix and why identity.ts is server-only.
What verification does and does not buy you
Does: an operator can see at a glance that the address was confirmed by your backend, so "this is Dana" is a fact rather than a claim. Conversations from the same verified address across devices stitch into one history.
Does not: prove the human at the keyboard is Dana. A shared laptop, a stolen session cookie or a colleague using an open browser all produce a verified conversation. Verification proves "this browser holds a session your server issued for this address", which is meaningfully stronger than nothing and meaningfully weaker than identity.
For anything genuinely sensitive (changing a payout account, deleting data, adding an admin) support should re-authenticate through your product, not the chat. Write that in the support runbook.
Sign out is part of the flow
export async function signOut() {
await auth.signOut();
resetSession(); // $crisp.push(["do", "session:reset"])
router.push("/");
}
Without the reset, the next person to use that browser opens the previous user's conversation history. On a shared or demo machine, that is a data leak that your own logs will never show, because as far as Crisp is concerned it is the same session.
Reset on sign-out only. Do not reset on a route change or a token refresh: that discards the conversation someone is in the middle of.
Anonymous visitors: do not guess
The temptation on a marketing page is to pull the email out of the newsletter form and set it. Do not. An unverified guess is worse than no label, because it looks the same as a verified one to an operator scanning an inbox.
The same rule covers the signed-in account whose email is null. It is tempting
to substitute the user id, or the string "unknown", so the sidebar is not
blank. Both put something in the address field that is not an address. Push the
nickname if you have one, push nothing if you do not.
Leave anonymous conversations anonymous, and rely on segments and session data (the page, the plan they were looking at) for context.
Checking that it works
- Sign in, open the chat, send a message.
- In your Crisp inbox, the conversation shows the address with the verified indicator. If it does not, the secret is missing or the signature is being computed over a different string than the address you send.
- Open DevTools and run
$crisp.push(["set","user:email",["someone.else@example.com"]]). The address changes and the verified indicator disappears, which is exactly the signal your support team needs. - Sign out and confirm the conversation history is gone.
- Grep the client bundle for the secret:
rg -l "CRISP_IDENTITY_SECRET" .next/staticmust return nothing.