# Identity verification

URL: https://aihio.ai/en/help/ai-agent/identity-verification\
Description: Pass server-signed user information and choose the right verification method for your chat window.

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**.

> **Warning — Use JWTs for actions involving private data:**
>
> A user-hash has no expiry and can be replayed after being supplied to a
> browser. It supports display personalization, not authorization to private
> data on its own. Use a short-lived JWT, an action requiring verified identity
> and authorization checks in your own endpoint.

## 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

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

```javascript
const crypto = require('crypto');

function computeUserHash(secret, userId) {
  return crypto.createHmac('sha256', secret).update(userId).digest('hex');
}
```

### Python

```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

```php
function compute_user_hash(string $secret, string $user_id): string {
    return hash_hmac('sha256', $user_id, $secret);
}
```

### Ruby

```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.

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

| 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

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`

```javascript
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`

```ruby
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')
end
```

### Django / Python (`PyJWT`)

Install: `pip install PyJWT`

```python
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,
        'iat': int(time.time()),
    }
    return jwt.encode(payload, secret, algorithm='HS256')
```

### PHP (`firebase/php-jwt`)

Install: `composer require firebase/php-jwt`

```php
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`)

Install: `go get github.com/golang-jwt/jwt/v5`

```go
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`)

Install (Maven): `io.jsonwebtoken:jjwt-api`, `jjwt-impl`, `jjwt-jackson` (runtime).

```java
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)

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

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:

```javascript
// Method A: HMAC user-hash
window.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 token
window.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:

```javascript
window.AihioWidget('resetUser');
```

### 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:

```javascript
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

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.

> **Danger — If the secret has been exposed:**
>
> Do not use normal rotation because it keeps the old secret valid for 24 hours.
> Select **Settings → Security → Identity verification → Remove**, then choose
> **Create key**. Deploy the new value to your server before enabling
> enforcement again.

### Routine rotation when the secret has not been exposed

1. **Rotate the secret**

   Press **Rotate** under **Settings → Security → Identity verification**.
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 **Settings → Security** view.

- **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, 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

- **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 `iat` claim. Without it, this age limit does not replace `exp`. Include both `iat` and `exp` in new tokens and test the chosen limit.

## 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

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.
- Include `iat` and a short `exp` on 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).
## Next steps

- [Custom HTTP Actions](/en/help/ai-agent/actions) — Define an HTTPS API call that the agent fills in and the server runs during a conversation. Credentials are stored encrypted and calls are protected.
