Skip to content

Documentation Index

Fetch the complete documentation index at:/llms.txt

Use this file to discover all available pages before exploring further.

Chatbot

Identity verification

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)
end

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.

Recommended token lifetime: 1 hour (exp = iat + 3600). Never issue tokens valid for more than 24 hours.

Supported JWT claims

ClaimTypeRequiredNotes
user_id or substringyes (either one)Your system’s user ID
external_idstringnoSubject alias; equivalent to user_id/sub
expnumber (Unix timestamp)yesExpiry; 30 s leeway applied
nbfnumber (Unix timestamp)noNot-before; 30 s leeway applied
emailstringnoPre-fills the pre-chat form
namestringnoPre-fills the pre-chat form
phonenumberstringnoPre-fills the pre-chat form
custom_attributesobjectnoArbitrary extra data
stripe_accountsarraynoUser’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')
end

Django / Python (PyJWT)

Install: pip install PyJWT

import time
import 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-hash
window.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 token
window.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.

  1. Rotate the secret

    Press Rotate secret in the Identity verification section of the Publish view.

  2. Update the environment variable

    Update AIHIO_IDENTITY_SECRET in your server environment with the new value.

  3. Deploy

    Deploy the new environment variable. The old secret continues to verify sessions in parallel for 24 hours.

  4. 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, or user_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 exp is 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_idPrechat 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 exp on 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 = true in your action handlers before returning account-scoped data.
  • Use lowercase hex output for HMAC (the default for all language functions above).
Last updated: 19/06/2026

Was this page helpful?