Skip to content

Identifying signed-in users in chat without letting anyone impersonate them

An email set from the browser is a claim anyone can make. Sign it server-side with HMAC, pass the signature through, and give your support team a label they can act on.

Crisp5 min readships at docs/solutions/crisp/identifying-signed-in-users-safely.md

Tags: crisp · support · security · hmac · impersonation · nextjs

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

  1. Sign in, open the chat, send a message.
  2. 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.
  3. 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.
  4. Sign out and confirm the conversation history is gone.
  5. Grep the client bundle for the secret: rg -l "CRISP_IDENTITY_SECRET" .next/static must return nothing.