# Identity verification

URL: https://aihio.ai/en/docs/chatbot/identity-verification
Description: Cryptographically bind an authenticated user to a widget session. HMAC user-hash or JWT – your server signs, Aihio verifies.

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

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

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`

```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,
  }
  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,
    }
    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,
    ];
    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(),
    })
    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))
        .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.

```javascript
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' },
);
```

> **This is an upcoming feature:**
>
> Identity verification and the `identity_verified` flag work today. Actions
> that read `stripe_accounts` data (subscriptions, invoices) are a separate
> upcoming feature. Sign the field now to make adoption seamless when it ships.

## Passing the proof to the widget

Compute the value server-side and inject it into the page, then call `identify`:

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

```javascript
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_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 `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).
