Documentation Index
Fetch the complete documentation index at:/llms.txt
Use this file to discover all available pages before exploring further.
Identity verification
On this page
Without identity verification, any visitor can call AihioWidget('identify', { user_id: '…' }) from the browser developer tools with any user ID. Identity verification ties each session to a server-signed secret so Aihio can confirm the caller is a genuinely authenticated user on your side.
Aihio supports two methods: HMAC user-hash and JWT HS256 token.
Where to find your secret
Open the chatbot’s Publish (Julkaise) view in the dashboard. The page contains an Identity verification section next to the chat-widget embed code. Press Generate secret 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
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
const crypto = require('crypto');
function computeUserHash(secret, userId) { return crypto.createHmac('sha256', secret).update(userId).digest('hex');}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()PHP
function compute_user_hash(string $secret, string $user_id): string { return hash_hmac('sha256', $user_id, $secret);}Ruby
require 'openssl'
def compute_user_hash(secret, user_id) OpenSSL::HMAC.hexdigest('SHA256', secret, user_id)endMethod 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.
Recommended token lifetime: 1 hour (exp = iat + 3600). Never issue tokens valid for more than 24 hours.
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 |
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 | Pre-fills the pre-chat form |
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
Sign the token with the chatbot secret using HS256. Set exp (1 hour recommended). custom_attributes is optional.
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)
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, } JWT.encode(payload, secret, 'HS256')endDjango / 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, } return jwt.encode(payload, secret, algorithm='HS256')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, ]; return JWT::encode($payload, $secret, 'HS256');}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(), }) return token.SignedString([]byte(secret))}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)) .signWith(key) .compact();}Passing Stripe accounts (upcoming feature)
Including a stripe_accounts array in the JWT associates the user with their Stripe customer records. The verified identity acts as the foundation for upcoming identity-scoped actions that retrieve subscriptions and invoices without re-authentication.
jwt.sign( { user_id: user.id, email: user.email, stripe_accounts: [{ label: 'Main account', stripe_id: 'cus_xxx' }], exp: Math.floor(Date.now() / 1000) + 3600, }, secret, { algorithm: 'HS256' },);Passing the proof to the widget
Compute the value server-side and inject it into the page, then call identify:
// Method A: HMAC user-hashwindow.AihioWidget('identify', { user_id: currentUser.id, // 'externalId' is also accepted as an alias email: currentUser.email, name: currentUser.name, user_hash: '{{ user_hash_from_server }}',});
// Method B: JWT tokenwindow.AihioWidget('identify', { token: '{{ signed_jwt_from_server }}',});You can call identify at any point after init. If you call it before the bundle has loaded, the command is queued and replayed automatically on initialisation.
Clear the identity on logout:
window.AihioWidget('resetUser');Secret rotation
Rotating a secret does not immediately invalidate the previous one. The previous secret remains valid for 24 hours after rotation. This matches Stripe’s webhook-secret rotation model: you can update the signing code across your environments before the old secret expires, without any downtime.
Rotate the secret
Press Rotate secret in the Identity verification section of the Publish view.
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
By default verification is fail-open. You can opt in to enforcement from the chatbot’s Publish → Secure tab.
- 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.
Enforcement unlocks only after Aihio has seen at least one valid token, so you cannot lock yourself out by enabling it before your integration is live.
Secure transport and session duration
- Secure transport only. When enabled, the widget sends identity headers and persists identity only over HTTPS. On a plain HTTP page it sends no identity and the server rejects identity verification.
- Session duration. Set a maximum accepted token age (60 seconds to 30 days). The widget clears the stored identity once the age is exceeded, and the server rejects any token older than the limit even if its own
expis still valid.
Rollout behavior: fail-open
The current implementation is fail-open: if no signature is supplied, or if verification fails, the session continues with identity_verified = false rather than being rejected.
This means an unverified user_id or email is accepted as a display hint but is technically attacker-controllable. Treat identity data as authoritative only when the conversation’s identity_verified flag is true.
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
- Store the chatbot secret server-side only. Never include it in client JavaScript or commit it to version control.
- Set
expon every JWT (1 h recommended). - Rotate the secret immediately if it is ever exposed, via the Identity verification section in the Publish view.
- Check
identity_verified = truein your action handlers before returning account-scoped data. - Use lowercase hex output for HMAC (the default for all language functions above).
Was this page helpful?