Identity verification
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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
Your backend signs the user's external ID with the web channel's IDV secret using HMAC-SHA256, hex-encoded.
The signed hash travels alongside the
externalIdto the widget.The widget sends both on
initand on the profile writes.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/rotateexists but rotates something else — the inbound credential for a custom API channel — and refuses any other channel type.)
Widget → Security → Identity verification
Click Generate secret
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
configis 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 |
|---|---|---|
|
| 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. |
|
| The HMAC key. |
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_requiredand has to re-initialise withidentity+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)
endPHP
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
endThe 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 |
|---|---|---|
|
| The exact string you signed. Don't sign |
|
| Hex-encoded HMAC-SHA256 of |
|
| Same as the regular identify call. None of these are part of the hash — only |
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:
enabled = false→ nothing to check. IDV is opt-in per channel.No
identityat all → anonymous visitor, allowed. IDV has nothing to bind.identitywith anemailbut noexternalId→ 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 onexternal_id, never on email.identity.externalIdpresent,userHashmissing → 403,identity verification required: missing userHash.Recompute
HMAC-SHA256(secret, externalId)and constant-time compare. Different → 403,identity verification failed: userHash does not match externalId.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:
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).
In the dashboard, Generate secret and copy it.
Deploy the new secret to your backend and roll your pods.
Save the new secret in Chatly. Any page still holding a hash computed with the old secret 403s on its next
initand 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
userHashyou exposed and callidentifythemselves. 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
emailinstead ofexternalIdis a common mistake. The server only checks the hash againstexternalId— 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 thatCHATLY_IDV_SECRETbelongs to the channel whosepublicIdthat 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), sometimesString(user.id). Normalize the input before hashing. Recommended: always signString(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 whatGET /v1/channelsreturns 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.