> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jun.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Identify signed-in users

> Prove who your signed-in customer is with a short-lived HS256 token, so agents see a verified contact and the AI can act on their own account.

By default every visitor is anonymous. When your app knows who the customer is, your backend can sign a short token that says so. The widget sends it to the desk, the desk checks the signature, and from then on:

* agents see a **verified** contact (a ✓ next to the name) with the name, email and attributes your app sent;
* agents see the customer's [company](#company) and their team's other chats, and the AI knows which company they're from;
* the customer's chats follow them across browsers and devices;
* the AI's [actions](/ai/actions.md) can act on *their* account with `{user.id}`, `{user.email}`, `{user.name}` and `{user.<attribute>}`, which come from your token, never from the chat;
* actions can call your API with the customer's own key through `{user.secrets.NAME}`.

Identity only ever comes from a token the desk verifies. Nothing the page's JavaScript says about the user is trusted on its own.

## Without a token

If you only want agents to see who they're talking to, skip the server part and pass the user's id, name and email from the page:

```js theme={null}
window.JunDesk?.identify({ id: user.id, name: user.name, email: user.email });
```

This works like PostHog's `identify`: it labels the visitor, nothing more. Agents see the name and email in the inbox, on **Contacts** and on the **Visitors** list, and the inbox's customer card says **Not verified**, since any visitor could call it with any email from the browser console. Email replies go to that address.

None of the list above applies. Without a token:

| | Without a token | With a token |
| - | - | - |
| Name and email shown to agents | Yes, as not verified | Yes, with a ✓ |
| Chats follow them to other browsers and devices | No | Yes |
| Shown as one person with other browsers that have the same email or id | Yes, on the desk only, as not verified ([Same person](#same-person-on-several-browsers)) | Yes, merged by `sub` ([Merging rules](#merging-rules)) |
| Chats from those other browsers shown in the widget | No, never | Yes |
| Company and attributes | No | Yes |
| The AI treats them as the signed-in customer | No | Yes |
| Actions with `{user.*}`, card buttons that run actions | No | Yes |

A few rules:

* A malformed email is dropped. A name is kept to one line of up to 120 characters, an id to 200 characters (a number becomes text).
* A verified contact keeps the details its token gave. A later call without a token doesn't change them.
* If the same browser later says a **different** email or id, it starts a new contact. On a shared computer, one person's chats never show under the next person's name. Still call `JunDesk.logout()` on sign-out.

### Same person on several browsers

Each browser is its own contact, but contacts that share an email or a user id are linked into **one person** on the desk. That covers emails from `identify()`, from the chat's email ask and from an agent's edit, plus ids from `identify()` and from a verified token's `sub`. The **Contacts** list shows them as one row ("2 browsers") with all their chats. The inbox's customer card and the person's page list every chat and say **Same person: N other browsers, matched by email or user ID (not verified)**.

That's for your team only. Each browser's chat still shows only the chats it started there. The AI doesn't treat the visitor as signed in or reach their other chats. Two verified users are never linked, even with the same email, because your app says they're different people.

When the match is wrong, or misses someone, fix it on the person's page under **Contacts**: the **Same person** card lists each linked browser with **Unlink**, and **Link** finds another contact by name or email. After that, matching by email or user id leaves those contacts where you put them. Linking two signed-in users is refused. On **Visitors**, a person's browsers sit together, tagged **Same person**.

### From PostHog

If your app already calls `posthog.identify()`, add `data-identify-from="posthog"` to the script tag and skip the call above:

```html theme={null}
<script src="https://your-desk.example.com/widget.js" data-key="wk_..." data-identify-from="posthog" defer></script>
```

After consent, the widget reads PostHog's identified user (its `distinct_id`, plus `name` and `email` from the person properties you pass to `identify()`) and labels the visitor with them, again whenever `identify()` runs. It's the same label as `identify({ id, name, email })`: not verified. A visitor already signed in with a token is left alone. When your app calls `posthog.reset()` on sign-out, the widget logs the visitor out too, so the next person on that computer starts fresh.

To move up to a verified contact later, add the token to the same call: `JunDesk.identify({ id, name, email, token })`. The token wins, and `name` and `email` then come from its claims.

> [!TIP]
>
> **Let your AI set it up.** With the desk's [MCP server](/code/mcp.md#set-up-identity-with-your-ai) connected in the editor where your app's code is open, ask: *"Set up Jun identity verification for our app."* Your AI turns it on (`set_identity`), puts the secret in your server's environment, adds the signing function and the page code from the guide `jun://guide/identity`, and checks a real token with `check_identity_token`.

## How it works

**1. Create the identity secret**

Go to **Chat widget → Behaviour** and press **Create identity secret** on the **Identify signed-in customers** card. Only owners and admins see this card.

The secret starts with `jis_`. Use **Show** and **Copy** to get it, and store it on your server, for example as `JUN_IDENTITY_SECRET`. Never put it in front-end code.

**2. Sign a token on your server**

For the signed-in user, sign a JWT with **HS256** and the identity secret. Keep it short-lived (an hour is plenty). See [Claims](#claims) and the [examples](#server-examples).

**3. Pass the token to the widget**

Either put it on the snippet:

```html theme={null}
<script src="https://your-desk.example.com/widget.js" data-key="wk_…" data-user-token="eyJhbGciOi…" defer></script>
```

or call `JunDesk.identify()` once your app knows the user (for example after sign-in):

```js theme={null}
window.JunDesk?.identify(userToken);
```

The snippet loads with `defer`, so early in a page's life `window.JunDesk` may not exist yet. If your app can sign in before the loader has run, wait for its `jundesk:ready` event:

```js theme={null}
function junIdentify(token) {
  if (window.JunDesk) window.JunDesk.identify(token);
  else window.addEventListener("jundesk:ready", () => window.JunDesk.identify(token), { once: true });
}

// A single-page app: ask your server for a token for the signed-in user.
fetch("/api/jun-token").then((r) => r.json()).then(({ token }) => junIdentify(token));
```

**4. Call logout when they sign out**

```js theme={null}
window.JunDesk?.logout();
```

See [Signing out](#signing-out).

The token travels two ways: over the loader's live connection (so the **Visitors** list shows who's on your site) and from the chat frame when it loads (so the conversation belongs to the right contact). With the round button (chat window, greeting card off), the chat frame loads on the first click, so that's when the second check happens.

## Claims

| Claim | Required | What it does |
| - | - | - |
| `sub` | Yes | Your user's id, as a string or number (up to 200 characters). This is the contact's identity: same `sub`, same contact. |
| `exp` | Yes | Expiry, in seconds since the epoch. Expired tokens are rejected. |
| `nbf` | No | Not before, in seconds. A token isn't accepted before it. |
| `email` | No | Shown to agents. Up to 320 characters. |
| `name` | No | Shown to agents and used by the AI to greet the customer. Up to 200 characters. |
| `attributes` | No | An object of facts about the customer, like `{ "plan": "pro", "seats": 12 }`. |
| `company` | No | The customer's company (their account in your app), like `{ "id": "org_123", "name": "Acme", "attributes": { "plan": "enterprise" } }`. See [Company](#company). |
| `secrets` | No | The customer's own keys for your API, like `{ "API_TOKEN": "…" }`. See [User secrets](#user-secrets). |

The desk allows 60 seconds of clock skew on `exp` and `nbf`.

### Attributes

* Up to 50 keys. Each key must start with a letter or `_`, use only letters, digits and `_`, and be at most 40 characters. Other keys are skipped.
* Values may be strings (cut to 500 characters), finite numbers or booleans. Anything else (objects, lists, `null`) is skipped.
* Each verified token **replaces** the contact's attributes: your app is the source of truth. `name` and `email` are updated when present and kept when left out.

### Company

For B2B apps, say which company the customer belongs to:

```json theme={null}
{
  "sub": "u_123",
  "exp": 1767225600,
  "company": { "id": "org_123", "name": "Acme", "attributes": { "plan": "enterprise", "seats": 40 } }
}
```

| Field | Required | What it does |
| - | - | - |
| `id` | Yes | Your app's id for the company, as a string or number (up to 200 characters). Same `id`, same company. |
| `name` | No | Shown to agents and the AI. Up to 200 characters. |
| `attributes` | No | Facts about the company. Same rules as the customer's [attributes](#attributes). |

* The desk keeps one company per `id` in the workspace. Its name and attributes are **replaced** by the latest token that names it: your app is the source of truth.
* A token without `company` unlinks the customer from their company.
* Agents see a **Company** section in the conversation's side panel (name, company id, attributes, how many of its people have signed in) and **Their team's conversations**: the latest 10 chats from the company's other people. See [Contact details](/inbox/visitors.md#contact-details).
* The AI sees the company's name and attributes with the customer's, like `company: Acme (plan: enterprise, seats: 40)`.

> [!NOTE]
>
> Actions can't use `{company.*}` placeholders yet. To send a company fact to your API, put it in the customer's `attributes` too and use `{user.<attribute>}`.

### Rejected tokens

A token is rejected, and the visitor stays anonymous, when:

| Reason | Message in the browser console |
| - | - |
| Not three dot-separated parts, or unreadable JSON | `Malformed identity token.` |
| Signed with anything other than HS256 | `Identity tokens must be signed with HS256.` |
| Wrong secret, or a rotated one | `Identity token signature doesn't match. Was it signed with this desk's identity secret?` |
| No `exp`, or it has passed | `Identity tokens need an exp (expiry) claim.` / `Identity token has expired.` |
| `nbf` in the future | `Identity token isn't valid yet.` |
| No `sub` | `Identity tokens need a sub (user id) claim.` |
| No identity secret on the desk | `Identity verification isn't set up for this desk (Chat widget → Behaviour → Identify signed-in customers).` |

The chat frame logs these as `[Jun] identify failed: …`. Bad `attributes` (not an object), a `company` that isn't an object or has no `id`, or bad `secrets` also reject the token.

## Server examples

```js Node.js (jsonwebtoken) theme={null}
import jwt from "jsonwebtoken";

// For the signed-in user, e.g. in the handler that renders your app's page.
const userToken = jwt.sign(
  {
    sub: user.id,
    email: user.email,
    name: user.name,
    attributes: { plan: user.plan, seats: user.seats },
    // Optional: the company (account) the user belongs to.
    company: { id: user.org.id, name: user.org.name, attributes: { plan: user.org.plan, seats: user.org.seats } },
    // Optional: the user's own key for your API, for actions that act as them.
    secrets: { API_TOKEN: user.apiToken },
  },
  process.env.JUN_IDENTITY_SECRET,
  { algorithm: "HS256", expiresIn: "1h" },
);
```

```js Node.js (no dependencies) theme={null}
import { createHmac } from "node:crypto";

const b64url = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");

export function junUserToken(user) {
  const header = b64url({ alg: "HS256", typ: "JWT" });
  const payload = b64url({
    sub: String(user.id),
    email: user.email,
    name: user.name,
    attributes: { plan: user.plan },
    company: { id: String(user.org.id), name: user.org.name, attributes: { plan: user.org.plan } },
    exp: Math.floor(Date.now() / 1000) + 60 * 60,
  });
  const signature = createHmac("sha256", process.env.JUN_IDENTITY_SECRET)
    .update(`${header}.${payload}`)
    .digest("base64url");
  return `${header}.${payload}.${signature}`;
}
```

```python Python (PyJWT) theme={null}
import os
import time

import jwt  # pip install PyJWT

def jun_user_token(user):
    return jwt.encode(
        {
            "sub": str(user.id),
            "email": user.email,
            "name": user.name,
            "attributes": {"plan": user.plan, "seats": user.seats},
            "company": {
                "id": str(user.org.id),
                "name": user.org.name,
                "attributes": {"plan": user.org.plan, "seats": user.org.seats},
            },
            "exp": int(time.time()) + 3600,
        },
        os.environ["JUN_IDENTITY_SECRET"],
        algorithm="HS256",
    )
```

```ruby Ruby (jwt gem) theme={null}
require "jwt"

def jun_user_token(user)
  claims = {
    sub: user.id.to_s,
    email: user.email,
    name: user.name,
    attributes: { plan: user.plan },
    company: { id: user.org.id.to_s, name: user.org.name, attributes: { plan: user.org.plan } },
    exp: Time.now.to_i + 3600,
  }
  JWT.encode(claims, ENV.fetch("JUN_IDENTITY_SECRET"), "HS256")
end
```

```php PHP (firebase/php-jwt) theme={null}
use Firebase\JWT\JWT;

function jun_user_token($user) {
    $claims = [
        "sub" => (string) $user->id,
        "email" => $user->email,
        "name" => $user->name,
        "attributes" => ["plan" => $user->plan],
        "company" => [
            "id" => (string) $user->org->id,
            "name" => $user->org->name,
            "attributes" => ["plan" => $user->org->plan],
        ],
        "exp" => time() + 3600,
    ];
    return JWT::encode($claims, getenv("JUN_IDENTITY_SECRET"), "HS256");
}
```

To check what your server makes, give a token to the MCP tool `check_identity_token`: it answers exactly as the chat would. Don't paste tokens into third-party decoder sites: a token with a `secrets` claim carries the customer's own key.

Then hand the token to the page, for example by rendering it into the snippet's `data-user-token` attribute, or by returning it from an endpoint your front end calls before `JunDesk.identify(token)`.

> [!TIP]
>
> The token only needs to be valid when the widget sends it. Once verified, the browser stays linked to the contact, so a one-hour token doesn't log the customer out of the chat after an hour. Only [user secrets](#user-secrets) end with the token's `exp`.

## Merging rules

What happens when a browser identifies depends on who it was before:

| The browser was… | Result |
| - | - |
| Anonymous, and this user is new to the desk | The anonymous contact becomes the user. Their chats stay as they are. |
| Anonymous, and this user already has a contact | The anonymous visitor's conversations move to the user's contact, and the anonymous contact is removed. |
| Already this user | The profile is refreshed (name, email, attributes, company). |
| Another identified user (a shared computer) | The browser gets a fresh link to this user. Nothing is merged. |

Identified contacts never merge with each other, so one user can never inherit another user's history. Merged conversations update in agents' inboxes right away.

## Signing out

Call `JunDesk.logout()` when the customer signs out of your app. It:

* forgets the chat identity in this browser, so the next person starts fresh, with no access to the last person's conversations;
* drops the customer's [user secrets](#user-secrets) on the desk right away;
* starts a new visitor session on the **Visitors** list.

Calling `JunDesk.identify(null)` also forgets the chat identity in the frame, but doesn't start a new session. Use `logout()`.

## User secrets

Some APIs only accept the customer's own key. Put it in the token's `secrets` claim:

```json theme={null}
{ "sub": "u_123", "exp": 1767225600, "secrets": { "API_TOKEN": "cust_live_…" } }
```

Then use it in an action's headers as `{user.secrets.API_TOKEN}` ([Actions](/ai/actions.md)).

* Names are UPPER\_SNAKE\_CASE: a capital letter, then capitals, digits and `_`, up to 64 characters.
* Up to 10 secrets per token, each a non-empty string of up to 4,096 characters. A bad name or value rejects the whole token.
* The desk keeps them only until the token's `exp`, or until `JunDesk.logout()`. They're kept apart from the contact: never stored with it, never shown to agents, never put in the AI's prompt, and masked in action responses.
* To keep them past `exp`, sign a fresh token and call `JunDesk.identify()` again.

## Rotate or turn off the secret

On the **Identify signed-in customers** card:

* **Rotate secret** creates a new one. Every token signed with the old secret stops working at once, so update your server first or right after.
* **Turn off** removes the secret. Everyone becomes anonymous, and identify calls fail with "Identity verification isn't set up for this desk".

Over the [MCP server](/code/mcp-tools.md#signed-in-customers), `set_identity` does the same with `turn: "rotate"` or `turn: "off"`, and refuses both until it's called with `confirm: true`.

> **[Actions](/ai/actions.md)**
>
> Use `{user.id}` and `{user.secrets.NAME}` in your API calls.

> **[JavaScript API](/widget/javascript-api.md)**
>
> `identify`, `logout` and the rest.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.