# 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.

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:

```js
$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

```tsx
// 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:

```tsx
// 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:

```ts
// 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:

```tsx
// 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:

```ts
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

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
