Identity verification
Pass server-signed user information and choose the right verification method for your chat window.
Open copy menu
Without identity verification, a visitor can supply any user ID in an identify call. Sign identity data on your server only after checking the user’s authenticated session. Aihio checks that signature, not your application’s sign-in process.
Aihio supports two methods: HMAC user-hash and JWT HS256 token.
Where to find your secret
Section titled “Where to find your secret”Open the chatbot’s Settings → Security page in the dashboard. In the Identity verification section, press Create key and copy the value immediately: it is shown only once. The secret looks like aihio_idv_….
Store it in your server-side environment (e.g. AIHIO_IDENTITY_SECRET). Never include it in client-side JavaScript or commit it to version control.
Method A: HMAC user-hash
Section titled “Method A: HMAC user-hash”Compute HMAC-SHA256(secret, user_id) on your server and pass the result as the user_hash field. The output must be lowercase hex. Aihio rejects uppercase hex.
All examples below produce the correct format by default.
Node.js
Section titled “Node.js”const crypto = require('crypto');
function computeUserHash(secret, userId) { return crypto.createHmac('sha256', secret).update(userId).digest('hex');}Python
Section titled “Python”import hmac, hashlib
def compute_user_hash(secret: str, user_id: str) -> str: return hmac.new( secret.encode('utf-8'), user_id.encode('utf-8'), hashlib.sha256 ).hexdigest()function compute_user_hash(string $secret, string $user_id): string { return hash_hmac('sha256', $user_id, $secret);}require 'openssl'
def compute_user_hash(secret, user_id) OpenSSL::HMAC.hexdigest('SHA256', secret, user_id)endMethod B: JWT HS256 token
Section titled “Method B: JWT HS256 token”Sign a JWT with the HS256 algorithm using the chatbot secret. exp is required: Aihio rejects tokens without it. Clock skew tolerance is 30 seconds.
Choose a short lifetime suitable for your integration, for example one hour (exp = iat + 3600). This is an example choice, not a universal maximum enforced by the service. Include iat so that the server can also check the configured maximum token age.
Supported JWT claims
Section titled “Supported JWT claims”| Claim | Type | Required | Notes |
|---|---|---|---|
user_id or sub | string | yes (either one) | Your system’s user ID |
external_id | string | no | Subject alias; equivalent to user_id/sub |
exp | number (Unix timestamp) | yes | Expiry; 30 s leeway applied |
iat | number (Unix timestamp) | Recommended | Issue time used to check maximum token age |
nbf | number (Unix timestamp) | no | Not-before; 30 s leeway applied |
email | string | no | Pre-fills the pre-chat form |
name | string | no | Pre-fills the pre-chat form |
phonenumber | string | no | Accepted by the verifier; not a guarantee of form prefill |
custom_attributes | object | no | Arbitrary extra data |
stripe_accounts | array | no | User’s Stripe accounts (see below) |
Only HS256 is accepted. RS256, ES256, or alg: none tokens are rejected.
Sign a JWT on your server
Section titled “Sign a JWT on your server”Sign the token with the chatbot secret using HS256. Set exp (1 hour recommended). custom_attributes is optional.
Node.js (jsonwebtoken)
Section titled “Node.js (jsonwebtoken)”Install: npm install jsonwebtoken
const jwt = require('jsonwebtoken');
function signIdentityToken(secret, user) { return jwt.sign( { user_id: user.id, email: user.email, name: user.name, custom_attributes: { plan: user.plan }, }, secret, { algorithm: 'HS256', expiresIn: '1h' }, );}Ruby on Rails (jwt gem)
Section titled “Ruby on Rails (jwt gem)”Install: bundle add jwt
require 'jwt'
def sign_identity_token(secret, user) payload = { user_id: user.id, email: user.email, name: user.name, custom_attributes: { plan: user.plan }, exp: Time.now.to_i + 3600, iat: Time.now.to_i, } JWT.encode(payload, secret, 'HS256')endDjango / Python (PyJWT)
Section titled “Django / Python (PyJWT)”Install: pip install PyJWT
import timeimport jwt
def sign_identity_token(secret: str, user) -> str: payload = { 'user_id': user.id, 'email': user.email, 'name': user.name, 'custom_attributes': {'plan': user.plan}, 'exp': int(time.time()) + 3600, 'iat': int(time.time()), } return jwt.encode(payload, secret, algorithm='HS256')PHP (firebase/php-jwt)
Section titled “PHP (firebase/php-jwt)”Install: composer require firebase/php-jwt
use Firebase\JWT\JWT;
function sign_identity_token(string $secret, $user): string { $payload = [ 'user_id' => $user->id, 'email' => $user->email, 'name' => $user->name, 'custom_attributes' => ['plan' => $user->plan], 'exp' => time() + 3600, 'iat' => time(), ]; return JWT::encode($payload, $secret, 'HS256');}Go (golang-jwt)
Section titled “Go (golang-jwt)”Install: go get github.com/golang-jwt/jwt/v5
import ( "time"
"github.com/golang-jwt/jwt/v5")
func SignIdentityToken(secret string, user User) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": user.ID, "email": user.Email, "name": user.Name, "custom_attributes": map[string]any{"plan": user.Plan}, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), }) return token.SignedString([]byte(secret))}Java (jjwt)
Section titled “Java (jjwt)”Install (Maven): io.jsonwebtoken:jjwt-api, jjwt-impl, jjwt-jackson (runtime).
import io.jsonwebtoken.Jwts;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.util.Date;import java.util.Map;
String signIdentityToken(String secret, User user) { SecretKeySpec key = new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); return Jwts.builder() .claim("user_id", user.getId()) .claim("email", user.getEmail()) .claim("name", user.getName()) .claim("custom_attributes", Map.of("plan", user.getPlan())) .expiration(new Date(System.currentTimeMillis() + 3_600_000)) .issuedAt(new Date()) .signWith(key) .compact();}Passing Stripe accounts (upcoming feature)
Section titled “Passing Stripe accounts (upcoming feature)”The verifier accepts stripe_accounts, but this does not enable retrieving Stripe subscriptions or invoices. Do not send these records in anticipation of future functionality. Include only fields needed by your current integration.
Passing the proof to the widget
Section titled “Passing the proof to the widget”Compute the proof server-side and pass it to the signed-in user through a protected response. Replace the placeholders below with those values, never the signing secret. Choose only one of the two methods:
// Method A: HMAC user-hashwindow.AihioWidget('identify', { externalId: currentUser.id, // 'user_id' is also accepted; externalId takes precedence. email: currentUser.email, name: currentUser.name, user_hash: '{{ user_hash_from_server }}',});
// Method B: JWT tokenwindow.AihioWidget('identify', { token: '{{ signed_jwt_from_server }}',});currentUser means your application’s authenticated user, not an arbitrary ID selected in the browser. Call identify after init. If the chat window has not loaded yet, the embed snippet queues the command for initialization.
Clear the identity on logout. This does not delete conversations stored by the service:
window.AihioWidget('resetUser');Keep a signed-in visitor’s conversation
Section titled “Keep a signed-in visitor’s conversation”A conversation that a verified visitor starts is bound to that visitor’s external_id, whether or not enforcement is on. Only a request whose JWT or user hash verifies to the same external_id can continue it, read an agent’s replies in it or leave a message in it. A visitor without proof, or another signed-in user, cannot continue it by knowing its reference. They can start a conversation of their own.
A JWT expires, so give the chat window a way to get a new one for the same user:
window.AihioWidget('identify', { token: '{{ signed_jwt_from_server }}', getToken: async () => { const response = await fetch('/api/aihio-token'); return (await response.json()).token; },});When the server refuses an expired token, or one older than the maximum token age, the chat window calls getToken once and sends the request again with the new token. Requests made at the same time share one call, and the window waits for it at most 10 seconds. If getToken is missing, fails or returns nothing, the chat window asks the visitor to sign in again instead of repeating the request. A handoff to an agent stays open and continues once identify passes a valid token.
Secret rotation
Section titled “Secret rotation”Rotating a secret does not immediately invalidate the previous one. The previous secret remains valid for 24 hours after rotation. Update the signing configuration in every environment before that window ends.
Routine rotation when the secret has not been exposed
Section titled “Routine rotation when the secret has not been exposed”Rotate the secret
Press Rotate under Settings → Security → Identity verification.
Update the environment variable
Update
AIHIO_IDENTITY_SECRETin your server environment with the new value.Deploy
Deploy the new environment variable. The old secret continues to verify sessions in parallel for 24 hours.
Wait 24 hours
After 24 hours the old secret stops being accepted. The vault entry is retained but no longer used for verification.
Enforce identity verification
Section titled “Enforce identity verification”By default verification is fail-open. You can opt in to enforcement from the chatbot’s Settings → Security view.
- Enforce identity verification. Any request that claims an identity (
user_id/external_id,email,token, oruser_hash) without a valid signature is rejected with HTTP 403. Anonymous visitors can still chat. - Require authentication (strict). Every unverified request is rejected, including anonymous ones.
When enforcement is on, an anonymous conversation is also bound to the verified visitor who continues it. The binding covers human support: an agent’s replies reach only the same verified visitor. Pass getToken so that an expired token is renewed while an agent handles the conversation.
Enable enforcement only after a successful verification test. Test a valid JWT, an expired JWT and a visitor without identity separately. Check that the chosen anonymous-access policy matches your intention.
Secure transport and session duration
Section titled “Secure transport and session duration”- Secure transport only. When enabled, the chat window passes and stores identity only over HTTPS. On an HTTP page it omits identity; server enforcement settings determine whether anonymous conversation is allowed.
- Session duration. Set a maximum accepted token age. The server can check age using a numeric
iatclaim. Without it, this age limit does not replaceexp. Include bothiatandexpin new tokens and test the chosen limit.
Rollout behavior: fail-open
Section titled “Rollout behavior: fail-open”When enforcement is disabled, missing or failed verification allows the conversation to continue with identity_verified = false. Enforcement and strict authentication change that behavior as described above.
An unverified user_id or email is a caller-supplied display hint. The identity_verified value alone does not distinguish a verified JWT from a user-hash. An action involving private data needs JWT verification and a separate authorization check.
Signed vs unsigned data
Section titled “Signed vs unsigned data”Only data signed inside the JWT is verified and safe to trust. Everything else is a display hint that a caller could forge.
| Signed (trusted) | Unsigned (hint only) |
|---|---|
user_id / external_id | Prechat form name, email and consent |
email, name, custom_attributes (when carried inside the JWT) | Any x-identify header sent without a JWT |
The HMAC user-hash (Method A) verifies only the external_id. To trust email, name or custom attributes, sign them inside a JWT (Method B). Caller-supplied fields are accepted as display hints but are never recorded as verified, and the conversation’s identity_verified flag stays false unless a valid signature is present.
Security checklist
Section titled “Security checklist”- Store the chatbot secret server-side only. Never include it in client JavaScript or commit it to version control.
- Include
iatand a shortexpon every JWT. - If the secret is exposed, select Settings → Security → Identity verification → Remove, create a new key, deploy it, and verify the replacement before re-enabling enforcement. Do not use routine rotation to revoke an exposed secret.
- Require JWT verification for actions involving private data. Also check in your own endpoint that the user may access the particular requested information.
- Use lowercase hex output for HMAC (the default for all language functions above).