Widget and identity
Customer identity
Customer identity is how your own app tells Zono who a signed-in person is, so the live chat widget and the customer portal recognise your users instead of asking them to create a second account. Your server signs what it knows with one workspace secret; Zono checks the signature and puts every channel on one customer record.
What identity is, and how far Zono trusts it
- Zono does not hold your user database. A hash or a token is your server's assertion: "this visitor is user 42, and this is their email". Zono accepts it because only your server and Zono know the secret that signed it.
- That is the same trust as the widget hash: whoever holds the secret can sign any identity. Keep it on your server, out of the browser, your repository and your logs, and rotate it if it leaks.
- The email_verified claim is how you say whether your app verified the address. With true, Zono may match the sign-in to an existing customer with that email and marks the address verified. With false, Zono ignores the email completely: it never matches, moves or verifies a customer record, and only the id (sub) identifies the person. A new customer then gets a placeholder address.
- The user id always wins. A sign-in whose id Zono has seen before lands on that customer, whatever email it carries. If the id is new but the verified email belongs to a customer with a different id, the sign-in is refused (identity_conflict) rather than merged.
- A customer who registered on the portal with a password and never verified their email loses that password the moment your app proves the address, so whoever registered an address they do not own cannot keep access.
- Sign only what you have verified yourself. Set email_verified to true only when your app confirmed the address, and never build a token from data a visitor typed into a form.
The widget hash and the portal token share one secret but do different jobs. Embed the widget on your site: sign the user id. Let customers into the portal without a second login: mint a token. Most teams that use the portal use both, and the Customer identity page in your workspace settings shows which steps have worked.
Generate your secret under Settings > Customer sign-in > Identity verification in your workspace settings. The same page lists your portal origins and tracks the first signed widget session, the first token and the first portal sign-in.
The signing key
Portal tokens are not signed with the identity secret itself. The signing key is derived from it, so a widget hash (which your pages print for every user) can never double as a token signature.
key = hex(SHA-256("zono-portal-sso-v1:" + secret))- The key is the 64-character lowercase hex string that comes out of SHA-256, used as the HMAC key exactly as that text. Do not decode it back to 32 bytes.
- Use the whole secret as Zono shows it, including the cis_ prefix.
- The widget hash keeps using the secret itself: hex(HMAC-SHA256(secret, identifier)). Only portal tokens use the derived key.
- Zono verifies against the derived key of the current secret and, during its grace window, of the previous one.
Widget: sign the user id
Sign the user id on your server and pass it to the widget as user.hash. Use this when you embed the chat widget on your own pages.
- hash = hex(HMAC-SHA256(secret, identifier)). The identifier is the user id as a string, trimmed. With no id it is the email, trimmed and lowercased.
- Sign exactly what you send to the widget: if it gets id 42, sign "42". The name, and the email when you sign an id, are not covered by the hash.
- A valid hash links the chat to that customer on every device and folds the visitor's anonymous chat on this browser into it. A hash Zono cannot verify is refused and the widget continues as an anonymous visitor.
- The workspace secret is checked first, then the secret of the live chat channel itself, so embeds signed with a channel secret keep working.
function zonoWidgetHash(string $secret, ?string $userId, ?string $email = null): string
{
// The user id as a string, or the lowercased, trimmed email when there is no id.
$id = trim((string) $userId);
$identifier = $id !== '' ? $id : mb_strtolower(trim((string) $email));
return hash_hmac('sha256', $identifier, $secret);
}
// $userHash = zonoWidgetHash(getenv('ZONO_IDENTITY_SECRET'), (string) $user->id); Pass the result to the widget as user.hash. The embed snippet and every option are in the widget reference .
Portal: the sign-in token
Mint a short-lived token on your server after your own sign-in and send the browser to the portal's /sso endpoint with it. Use this when customers should arrive at the portal already signed in.
https://{your portal host}/sso?token={jwt}&return_to={return_to}- The token is a compact JWT signed with HS256 using the derived key above. No JWT library is needed: the snippets sign it in a few lines with the standard library of each language.
- It must expire within 300 seconds of issue; 120 is plenty because the browser follows the redirect straight away. Zono tolerates 30 seconds of clock difference between your server and ours.
- Every token carries a unique jti and works once. A reload of the /sso URL, or a second use of the same link, is refused as replayed.
- aud is your workspace id, so a token minted for one workspace can never sign anyone in to another. The snippets on the Customer identity page already contain it.
- Zono does not read avatar unless it is an https URL, and ignores unknown claims.
- Signing in with a token while another customer is signed in on that browser replaces them. A refused token changes nothing: it cannot be used to sign someone out.
- The /sso endpoint is rate limited per IP address (30 requests a minute).
Claims
| Claim | Type | Description |
|---|---|---|
| sub* | string | Your user id, 1 to 191 characters. It becomes the customer's external id and is what the widget hash signs, so use the same id in both. |
| email* | string | The user's email address, a valid address of at most 255 characters. |
| email_verified* | boolean | Whether your app verified that address. A token without it is refused (missing_claims). false makes Zono ignore the email entirely. |
| name | string | Display name, at most 255 characters. It only fills a blank name; it never overwrites one a customer already has. |
| avatar | string | An https URL to a profile picture. Anything else is ignored. |
| iat* | integer | Issued at, in Unix seconds. It must not be in the future. |
| exp* | integer | Expiry in Unix seconds, after iat and at most 300 seconds after it. |
| jti* | string | A unique token id of 1 to 64 characters. Each value works once. |
| aud* | string | Your workspace id, shown in the snippets on the Customer identity page. |
Minting the token on your server
These snippets use only the standard library of each language: HS256 is a 10-line job, so there is no JWT dependency to audit. Any JWT library works too, as long as it signs HS256 with the derived key.
function zonoPortalToken(string $secret, string $teamId, array $user): string
{
$base64url = fn (string $data): string => rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
// The signing key is the hex SHA-256 of the context and the secret, used as that text.
$key = hash('sha256', 'zono-portal-sso-v1:'.$secret);
$now = time();
$claims = array_filter([
'sub' => (string) $user['id'],
'email' => $user['email'],
'email_verified' => (bool) $user['email_verified'],
'name' => $user['name'] ?? null,
'iat' => $now,
'exp' => $now + 120,
'jti' => bin2hex(random_bytes(16)),
'aud' => $teamId,
], fn ($value) => $value !== null);
$signingInput = $base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']))
.'.'.$base64url(json_encode($claims));
return $signingInput.'.'.$base64url(hash_hmac('sha256', $signingInput, $key, true));
}
// $token = zonoPortalToken(getenv('ZONO_IDENTITY_SECRET'), 'YOUR_TEAM_ID', [
// 'id' => $user->id,
// 'email' => $user->email,
// 'email_verified' => $user->hasVerifiedEmail(),
// 'name' => $user->name,
// ]);Login and logout redirects
- The portal's sign-in button sends the browser to the login URL you set in your portal settings, with return_to added: the absolute URL of the portal page the visitor was on. If your login URL already has a query string, return_to is appended with &.
- Your login URL signs the user in the way your app always does, or recognises the session they already have. A signed-in user needs no second login.
- Your server mints the token and redirects the browser to {origin of return_to}/sso?token={jwt}&return_to={return_to}, but only when that origin is one of your portal origins. Otherwise it redirects to your primary portal origin and leaves return_to out.
- The portal checks the token, signs the customer in with a remember-me cookie and follows return_to only when it is an absolute URL on the same host the request arrived on. Anything else goes to the portal dashboard.
- Signing out: when you set a logout URL, the portal's Sign out ends the portal session and then redirects there, so you can end your own session as well. Without one it returns to the portal sign-in page.
Check the return_to origin. This one is on you.
- You must check the return_to origin yourself. Your portal origins are the portal subdomain and every verified custom domain, listed on the Customer identity page. If your login endpoint redirects to whatever host return_to names, a crafted login link sends a fresh, valid token to any site the attacker chooses.
- Compare origins exactly: scheme, host and port. Reject return_to values with credentials ("https://[email protected]"), backslashes or whitespace, and never redirect to the raw return_to yourself: the only places you send a token are the listed origins, at the fixed path /sso.
- Pass return_to to /sso unchanged (URL-encoded). The portal makes its own same-host check, so it is a second line of defence, not a replacement for yours.
- Signing the user out of your app does not end their portal session on its own. The portal session ends at the portal's Sign out, which is why the logout URL exists.
function zonoPortalRedirectUrl(string $token, ?string $returnTo, array $portalOrigins, string $primaryOrigin): string
{
$origin = null;
if ($returnTo !== null && ! preg_match('/[\x00-\x20\x7f\\\\]/', $returnTo)) {
$parts = parse_url($returnTo);
if (is_array($parts) && isset($parts['scheme'], $parts['host']) && ! isset($parts['user']) && ! isset($parts['pass'])) {
$origin = strtolower($parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : ''));
}
}
// Only a portal origin of yours may receive a token, and always at the fixed path /sso.
if ($origin !== null && in_array($origin, array_map('strtolower', $portalOrigins), true)) {
return $origin.'/sso?'.http_build_query(['token' => $token, 'return_to' => $returnTo]);
}
return rtrim($primaryOrigin, '/').'/sso?'.http_build_query(['token' => $token]);
}
// return redirect(zonoPortalRedirectUrl($token, $request->query('return_to'), $portalOrigins, $portalOrigins[0]));Rotating the secret
- Generate or rotate the secret under Settings > Customer sign-in > Identity verification (admins and owners). The new secret is shown once: copy it then.
- Rotating keeps the previous secret verifying for 24 hours, for widget hashes and portal tokens alike. Deploy the new secret to your servers inside that window and nothing breaks.
- Revoking deletes the current and the previous secret at once. Widget hashes signed with them stop verifying, and a portal set to single sign-on falls back to email and password until a new secret exists.
- Live chat channels can still carry their own identity verification secret. It is checked after the workspace secret, and embeds signed with it keep working.
Failure reasons
A refused sign-in sends the visitor to the portal's login page with a short, generic message, and Zono records the reason (never the token or its claims) against your team. Match what you see to one of these.
| Reason | What happened | What to do |
|---|---|---|
malformedMalformed token | The token is missing or is not a compact HS256 JWT. | Send the token in the token query parameter, signed with HS256 (never "none" or RS256), as three base64url parts separated by dots. |
bad_signatureBad signature | The signature matches none of your signing keys. | Sign with the derived key, not the raw secret, and use the whole secret including its prefix. Check you deployed the current secret, or the previous one inside its grace window. |
expiredExpired token | The token has expired, was issued in the future, or lives longer than 300 seconds. | Mint the token right before the redirect with exp at most 300 seconds after iat, and keep your server clock in sync. |
wrong_audienceWrong audience | aud is not this workspace's id. | Set aud to your workspace id, as in the snippets on the Customer identity page. A token minted for another workspace is never accepted. |
replayedToken already used | This jti has already been used. | Generate a fresh random jti for every token and never redirect the same token twice. A browser refresh on the /sso URL also causes this. |
missing_claimsMissing or invalid claims | A required claim is missing, or a claim has the wrong type or length. | Send sub, email, email_verified (a boolean), iat, exp, jti and aud with the types in the claims table. |
identity_conflictEmail linked to another user | The verified email belongs to a customer that already has a different id. | Use one stable id per person. Nothing is changed; the visitor is asked to contact support, who can merge the two records. |
sso_disabledSSO is not enabled | The portal's identity mode is email and password, or single sign-on is set up without a login URL. | Choose single sign-on only, or single sign-on and password, in your portal settings and set the login URL. |
no_secretNo customer identity secret | Your workspace has no customer identity secret. | Generate one under Settings > Customer sign-in > Identity verification. |