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');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, a conversation is bound to the verified external_id. A reused reference_id whose stored identity does not match the verified token starts a fresh conversation instead of attaching, so one visitor cannot resume another visitor’s private 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).