Guides

Identity verification

AdminUpdated Sep 19, 2026

Identity verification

HMAC-sign your visitors so no one can impersonate them through the widget.

Identity Verification (IDV) is the single most important security setting for any authenticated app embedding the Chatly widget. Without it, anyone who copies your snippet onto their own site can impersonate any of your logged-in users by guessing their ID.

Danger — Turn this on before launch

If your product has authenticated users, IDV is not optional. The window between "we shipped the widget" and "we turned IDV on" is the exact window in which an attacker can scrape your customers' chat history.

The attack we're stopping

Imagine you ship the widget and call:

Chatly('identify', { externalId: user.id });

Now anyone, anywhere on the internet, can load your widget snippet from their own page and run that exact line of JavaScript with someone else's user ID. The widget happily attaches their session to that user, and the attacker now sees every conversation that user ever had with your support team.

IDV fixes this by requiring the identify call to include a userHash that only your backend can produce — because only your backend knows the IDV secret.

How it works

  1. Your backend signs the user's external ID with the web channel's IDV secret using HMAC-SHA256, hex-encoded.

  2. The signed hash travels alongside the externalId to the widget.

  3. The widget sends both on init and on the profile writes.

  4. The API recomputes the hash with the same secret and constant-time compares. Mismatch → 403, request rejected, no verified session.

The secret never leaves your backend. The widget can't compute the hash on its own. An attacker who only has your publicId cannot fake a signature.

Setup

1. Generate a secret

Warning — There is no rotate endpoint for the IDV secret

The IDV secret is part of the web channel's config, not a separately issued credential. Use the dashboard, which generates it in your browser and writes it in one request. (POST /v1/channels/{id}/secret/rotate exists but rotates something else — the inbound credential for a custom API channel — and refuses any other channel type.)

  1. Widget → Security → Identity verification

  2. Click Generate secret

  3. Copy it now — after you save, reads of the channel return __redacted__ in its place, so the dashboard can never show it again

The generated value is 32 random bytes as 64 lowercase hex characters. Saving writes it to the web channel:

PATCH /v1/channels/{id} — Bearer token

{
  "config": {
    "identityVerification": { "enabled": true, "secret": "…64 hex chars…" }
  }
}

Danger — PATCH replaces the whole config object

Anything you leave out of config is deleted — appearance, behaviour, everything. If you write this by hand rather than through the dashboard, read the channel first and spread the existing config.

config.identityVerification

Name

Type

Description

enabled (required)

bool

Off by default. Turning it on with no secret stored leaves the channel enabled-but-unverifiable and 403s every identity claim, so set both together.

secret (required)

string

The HMAC key. GET /v1/channels returns __redacted__ here; sending that literal back on a PATCH means "keep the stored secret" — but only when one is already stored, otherwise the key is dropped.

Warning — Turning IDV on ends existing widget sessions

Any visitor session minted before verification was enabled — a legacy token, or one that asserted an identity nobody checked — is refused with visitor_session_upgrade_required and has to re-initialise with identity + userHash. That is the point: those sessions were trusted on the customer's word.

Warning — Treat the secret like a database password

Anyone who has the IDV secret can impersonate your users to the widget. Store it as an env var, not in code. Rotate it if you suspect leakage.

2. Compute the hash per user

Node.js

import { createHmac } from 'node:crypto';

export function chatlyUserHash(externalId: string): string {
  return createHmac('sha256', process.env.CHATLY_IDV_SECRET!)
    .update(externalId)
    .digest('hex');
}

Or use the SDK helper, which is the same one-liner:

import { chatlyIdentityHash } from '@livechat/sdk-node';

const userHash = chatlyIdentityHash(
  process.env.CHATLY_IDV_SECRET!,
  user.id,
);

Python

import hmac, hashlib, os

def chatly_user_hash(external_id: str) -> str:
    return hmac.new(
        os.environ["CHATLY_IDV_SECRET"].encode(),
        external_id.encode(),
        hashlib.sha256,
    ).hexdigest()

Ruby

require 'openssl'

def chatly_user_hash(external_id)
  OpenSSL::HMAC.hexdigest('sha256', ENV.fetch('CHATLY_IDV_SECRET'), external_id)
end

PHP

function chatlyUserHash(string $externalId): string {
    return hash_hmac('sha256', $externalId, getenv('CHATLY_IDV_SECRET'));
}

Go

package chatly

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "os"
)

func UserHash(externalID string) string {
    h := hmac.New(sha256.New, []byte(os.Getenv("CHATLY_IDV_SECRET")))
    h.Write([]byte(externalID))
    return hex.EncodeToString(h.Sum(nil))
}

Elixir

defmodule Chatly do
  def user_hash(external_id) do
    secret = System.get_env("CHATLY_IDV_SECRET")
    :crypto.mac(:hmac, :sha256, secret, external_id)
    |> Base.encode16(case: :lower)
  end
end

The secret is used as the raw string it was copied as — do not hex-decode or base64-decode it first. A sha256: or sha256= prefix on the hash you send is tolerated; nothing else is.

3. Pass it to the widget

HTML / EJS

<script>
  Chatly('identify', {
    externalId: '<%= user.id %>',
    email: '<%= user.email %>',
    name: '<%= user.name %>',
    userHash: '<%= userHash %>'
  });
</script>

React

'use client';
import { useEffect } from 'react';

export function ChatlyIdentity({
  user,
  userHash,
}: {
  user: { id: string; email: string; name: string };
  userHash: string;
}) {
  useEffect(() => {
    window.Chatly?.('identify', {
      externalId: user.id,
      email: user.email,
      name: user.name,
      userHash,
    });
  }, [user, userHash]);
  return null;
}

Compute userHash server-side and pass it as a prop — never inline the secret in client code.

Next.js (server component)

// app/layout.tsx
import { chatlyIdentityHash } from '@livechat/sdk-node';
import { getSession } from '@/lib/auth';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession();
  const userHash = session
    ? chatlyIdentityHash(process.env.CHATLY_IDV_SECRET!, session.user.id)
    : null;

  return (
    <html>
      <body>
        {children}
        {session && (
          <script
            dangerouslySetInnerHTML={{
              __html: `Chatly('identify', ${JSON.stringify({
                externalId: session.user.id,
                email: session.user.email,
                name: session.user.name,
                userHash,
              })});`,
            }}
          />
        )}
      </body>
    </html>
  );
}

Identify payload (with IDV)

Name

Type

Description

externalId (required)

string

The exact string you signed. Don't sign user.id and pass user.uuid — the hash won't match.

userHash (required)

string

Hex-encoded HMAC-SHA256 of externalId using the channel's IDV secret. 64 hex chars.

email, name, attributes

various

Same as the regular identify call. None of these are part of the hash — only externalId is signed.

What the server checks

Verification runs on the three widget endpoints that can act for a visitor:

POST /v1/widget/init — Public

POST /v1/widget/profile — Public

POST /v1/widget/conversations/{id}/labels — Public

On init, with the channel's identityVerification config loaded:

  1. enabled = false → nothing to check. IDV is opt-in per channel.

  2. No identity at all → anonymous visitor, allowed. IDV has nothing to bind.

  3. identity with an email but no externalId → allowed, but the session stays unverified, so it still cannot perform the profile writes a verified session can. An email is not a claim to be anybody: contacts are matched on external_id, never on email.

  4. identity.externalId present, userHash missing → 403, identity verification required: missing userHash.

  5. Recompute HMAC-SHA256(secret, externalId) and constant-time compare. Different → 403, identity verification failed: userHash does not match externalId.

  6. Match → the session is minted verified, bound to this externalId.

/profile and /conversations/{id}/labels accept either a session token that was already minted verified — the stronger evidence, since the token cannot be produced without a server secret — or a userHash that verifies against the session's own visitor key.

The compare uses timingSafeEqual, so an attacker can't measure how much of the hash they got right.

Rotating the secret

Rotation is a hard cut. There is no grace period and no second accepted secret: generate a new one in the dashboard, save, and every userHash computed with the old secret stops verifying immediately.

Procedure:

  1. Update your backend so it can sign with the new secret, but keep serving the old one (a feature flag, or a second env var).

  2. In the dashboard, Generate secret and copy it.

  3. Deploy the new secret to your backend and roll your pods.

  4. Save the new secret in Chatly. Any page still holding a hash computed with the old secret 403s on its next init and re-identifies on the next load.

Tip — Multiple environments

If your dev, staging, and prod stacks each have a separate IDV-enabled channel, give each one its own secret. Don't share secrets across environments — that's how a leaked dev secret turns into a prod compromise.

What IDV does NOT protect

IDV verifies the server-stated identity of the visitor. It is not a universal security blanket.

  • Device theft. If the visitor's browser is compromised, the attacker reuses the existing widget session token. IDV can't stop that.

  • XSS on your site. If your page lets an attacker execute JS in the user's browser, they can read whatever userHash you exposed and call identify themselves. Don't put the secret on the client — only put the hash.

  • Anonymous visitors. IDV requires externalId. Anonymous visitors don't have one, so they bypass IDV by design.

  • Hash of the wrong field. Signing email instead of externalId is a common mistake. The server only checks the hash against externalId — pick a stable internal ID, not an email that can change.

Compatibility

This is an industry-standard pattern shared by Intercom, Drift, Help Scout, and Crisp. If you've implemented Intercom's "Identity Verification" or Drift's "user authentication," the same backend code works for Chatly — only the secret and the field name (userHash instead of user_hash) change.

Troubleshooting

Warning — 403 'userHash does not match externalId' in prod but fine in dev

Two channels, two secrets, and one environment is signing with the wrong one. Each web channel carries its own identityVerification.secret. Check that CHATLY_IDV_SECRET belongs to the channel whose publicId that page embeds.

Warning — Hash matches but session detaches on next page load

You're signing different values across pages — e.g. sometimes user.id (numeric), sometimes String(user.id). Normalize the input before hashing. Recommended: always sign String(user.id).

Warning — 403 visitor_session_upgrade_required

The browser is presenting a session token minted before verification was turned on for this channel. Expected, and self-healing: re-initialise the widget with identity + userHash.

Info — Saving the secret appeared to work but every claim now 403s

Something wrote the literal __redacted__ over the live key. That is what GET /v1/channels returns in place of the secret, and a hand-rolled PATCH that echoes the channel back verbatim will store it. Generate a fresh secret and save again.

Was this page helpful?