---
title: "Customer identity"
description: "Sign your users in to Gleap with an identity token from your backend: identity keys, setUserJwt, the session grant, protected fields, enforcement per platform and verified customers."
url: https://docs.gleap.ai/en/articles/709-customer-identity
collection: "Guides"
updated: 2026-11-07
language: en
---

# Customer identity

Sign your users in to Gleap with an identity token from your backend: identity keys, setUserJwt, the session grant, protected fields, enforcement per platform and verified customers.

Anyone can call `identify` in a browser with any user ID, so Gleap needs proof that the user really is who your app says. Your backend gives that proof: after login it signs a short **identity token** (a JWT) for the user, and your app hands it to Gleap. Gleap checks the token once and answers with a **session grant** that the SDK sends with every later request. Everything inside the token is **signed**: the user ID, the email, the company and attributes such as `stripeCustomerId`.

> **Info:** Upgrading an existing integration? [Upgrade to identity tokens](https://docs.gleap.ai/en/articles/16752008-upgrade-to-identity-tokens.md) explains what changed and how to move over step by step.

## How it works

1.  Create an **identity key** in **Settings → Security → Customer identity**.
    
2.  After your user logs in, your backend signs a token with that key.
    
3.  Your app passes the token to Gleap with `setUserJwt`, after login and on every app start.
    
4.  Gleap stores the signed fields on the contact and returns a session grant. The SDK sends it with every request, so Gleap knows each request is signed without checking the token again.
    

## Create an identity key

1.  Open **Settings → Security → Customer identity → Identity keys** and click **Create key**.
    
2.  Give it a label, for example "Production backend", and pick the **platform** it may sign for: all platforms (the default), web, iOS or Android.
    
3.  Copy the key ID and the secret. **Gleap shows the secret once.**
    
4.  Store both in your backend's configuration, for example as `GLEAP_IDENTITY_KEY_ID` and `GLEAP_IDENTITY_SECRET`.
    

A project holds up to five keys. Only admins can see and manage identity keys.

> **Warning:** Keep the identity key secret on your server. Never put it in your app, your frontend bundle or a URL.

## Sign the token on your backend

The token is an HS256 JWT with the key ID in the `kid` header. Everything the contact should show goes into the token.

<table style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Claim</p></th><th colspan="1" rowspan="1"><p></p></th></tr><tr><td colspan="1" rowspan="1"><p><code>userId</code></p></td><td colspan="1" rowspan="1"><p><strong>Required.</strong> Your stable user ID as a string (at most 200 characters). <code>user_id</code> works too.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>exp</code></p></td><td colspan="1" rowspan="1"><p><strong>Set it.</strong> Expiry in seconds since the epoch, 5 to 60 minutes ahead. It only gates the sign-in; the session itself runs on the grant. A token without <code>exp</code> never expires.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>email</code></p></td><td colspan="1" rowspan="1"><p>Optional. Only an email your system has confirmed. Signing it means you vouch for it.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>name</code></p></td><td colspan="1" rowspan="1"><p>Optional. The user's name.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>phone</code></p></td><td colspan="1" rowspan="1"><p>Optional. The user's phone number.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>company</code></p></td><td colspan="1" rowspan="1"><p>Optional. An object with <code>id</code> (required), <code>name</code> and further keys.</p></td></tr><tr><td colspan="1" rowspan="1"><p>anything else</p></td><td colspan="1" rowspan="1"><p>A <strong>signed attribute</strong>: string, number or boolean, stored as custom data. Use <code>stripeCustomerId</code>, <code>chargebeeCustomerId</code>, <code>paddleCustomerId</code>, <code>lemonsqueezyCustomerId</code>, <code>revenuecatAppUserId</code>, <code>shopifyCustomerId</code> and <code>woocommerceCustomerId</code> for your billing and shop IDs.</p></td></tr></tbody></table>

Limits: at most 50 attributes, names of at most 64 characters (letters, digits and `_`), string values of at most 1,000 characters, and at most 4,096 characters for the whole token. Gleap allows 60 seconds of clock skew.

**Node.js**

```js
// npm install jsonwebtoken
const jwt = require("jsonwebtoken");

const token = jwt.sign(
  {
    userId: String(user.id),
    email: user.email,
    name: user.name,
    company: { id: user.companyId, name: user.companyName },
    stripeCustomerId: user.stripeCustomerId,
  },
  process.env.GLEAP_IDENTITY_SECRET,
  { algorithm: "HS256", keyid: process.env.GLEAP_IDENTITY_KEY_ID, expiresIn: "30m" }
);
```

**PHP**

```php
// composer require firebase/php-jwt
use Firebase\JWT\JWT;

$token = JWT::encode(
    [
        'userId' => (string) $user->id,
        'email' => $user->email,
        'name' => $user->name,
        'company' => ['id' => $user->companyId, 'name' => $user->companyName],
        'exp' => time() + 1800,
    ],
    getenv('GLEAP_IDENTITY_SECRET'),
    'HS256',
    getenv('GLEAP_IDENTITY_KEY_ID')
);
```

**Python**

```py
# pip install PyJWT
import os
from datetime import datetime, timedelta, timezone
import jwt

token = jwt.encode(
    {
        "userId": str(user.id),
        "email": user.email,
        "name": user.name,
        "company": {"id": user.company_id, "name": user.company_name},
        "exp": datetime.now(timezone.utc) + timedelta(minutes=30),
    },
    os.environ["GLEAP_IDENTITY_SECRET"],
    algorithm="HS256",
    headers={"kid": os.environ["GLEAP_IDENTITY_KEY_ID"]},
)
```

**Ruby**

```ruby
# gem 'jwt'
require 'jwt'

token = JWT.encode(
  {
    userId: current_user.id.to_s,
    email: current_user.email,
    name: current_user.name,
    company: { id: current_user.company_id, name: current_user.company_name },
    exp: Time.now.to_i + 1800
  },
  ENV.fetch('GLEAP_IDENTITY_SECRET'),
  'HS256',
  { kid: ENV.fetch('GLEAP_IDENTITY_KEY_ID') }
)
```

**C# (.NET)**

```csharp
// dotnet add package System.IdentityModel.Tokens.Jwt
var key = new SymmetricSecurityKey(
    Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("GLEAP_IDENTITY_SECRET")))
{
    KeyId = Environment.GetEnvironmentVariable("GLEAP_IDENTITY_KEY_ID"),
};
var header = new JwtHeader(new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
var payload = new JwtPayload
{
    { "userId", user.Id.ToString() },
    { "email", user.Email },
    { "name", user.Name },
    { "exp", DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds() },
};
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(header, payload));
```

**Go**

```go
// go get github.com/golang-jwt/jwt/v5
claims := jwt.MapClaims{
    "userId": fmt.Sprint(user.ID),
    "email":  user.Email,
    "name":   user.Name,
    "exp":    time.Now().Add(30 * time.Minute).Unix(),
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
t.Header["kid"] = os.Getenv("GLEAP_IDENTITY_KEY_ID")
token, err := t.SignedString([]byte(os.Getenv("GLEAP_IDENTITY_SECRET")))
```

Return the token to your app, for example in the login response or from an endpoint such as `/api/gleap-token`. Take the user ID and email from your authenticated server session, never from a value the browser sent.

## Sign in from your app

Call `setUserJwt` after login **and on every app start or page load** of a logged-in user. The SDK keeps the token in memory only and stores the session grant with the session. `setUserJwtProvider` is optional: the SDK calls it for a fresh token when the grant is about to run out or a token was refused as expired. Call `clearIdentity` on logout and before you sign in another user.

**JavaScript**

```js
await Gleap.setUserJwt(token);

// Optional: fresh tokens for renewal
Gleap.setUserJwtProvider(async () => {
  const response = await fetch("/api/gleap-token", { credentials: "include" });
  return (await response.json()).token;
});

// On logout
Gleap.clearIdentity();
```

**iOS**

```swift
Gleap.setUserJwt(token)

// Optional: fresh tokens for renewal (pass nil when it failed)
Gleap.setUserJwtProvider { completion in
    MyBackend.fetchGleapToken { token in completion(token) }
}

// On logout
Gleap.clearIdentity()
```

**Android**

```kotlin
Gleap.getInstance().setUserJwt(token)

// Optional: fresh tokens for renewal (pass null when it failed)
Gleap.getInstance().setUserJwtProvider(GleapUserJwtProvider { callback ->
    myBackend.fetchGleapToken { token -> callback.onToken(token) }
})

// On logout
Gleap.getInstance().clearIdentity()
```

**React Native**

```js
import Gleap from "react-native-gleapsdk";

await Gleap.setUserJwt(token);
Gleap.setUserJwtProvider(async () => await fetchGleapToken());

// On logout
Gleap.clearIdentity();
```

**Flutter**

```dart
await Gleap.setUserJwt(token: token);
Gleap.setUserJwtProvider(provider: () async => await myBackend.fetchGleapToken());

// On logout
Gleap.clearIdentity();
```

**Capacitor**

```js
import { Gleap } from "capacitor-gleap-plugin";

await Gleap.setUserJwt({ token });
Gleap.setUserJwtProvider(async () => await fetchGleapToken());

// On logout
Gleap.clearIdentity();
```

You need JavaScript SDK 19.5.0 or newer, or 19.4.0 or newer of the iOS, Android, React Native, Flutter and Capacitor SDKs. The Cordova, .NET MAUI, Unity and Windows SDKs have no `setUserJwt` yet: pass the token as the signature argument of their identify call (see the user identity page of your SDK). That sign-in is verified, but those SDKs don't send the session grant yet, so their sessions count as unsigned afterwards.

`updateContact` still sends extra data such as the plan or the language, but it can't change [protected fields](#protected-fields). `setAuthTokens({ backend: accessToken })` gives [API tools](#tools-that-act-for-the-customer) a token to call your backend as this user; it works only for a signed-in user.

## The session grant

The token proves the user once. Gleap then hands back a session grant, signed by Gleap itself, and the SDK sends it with every request from the SDK, the Messenger and the help center. A request with a valid grant is a **signed request**.

-   **Lifetime.** A grant lasts as long as **Signed login lasts** on the Security page: 7 days by default, 1 to 30 days. The SDK option `sessionDuration` can only shorten it, to at least 5 minutes.
    
-   **The token's `exp` doesn't end the session.** A one-hour token doesn't log your user out after an hour.
    
-   **Renewal.** Before the grant ends, the SDK asks your provider for a fresh token and signs in again, so a chat in progress keeps working. Without a provider, the next `setUserJwt` call renews it.
    
-   **Logout.** `clearIdentity` drops the grant on this device. Revoking the key, or shortening **Signed login lasts**, ends grants everywhere.
    
-   **On the web** the grant also lives in the cookie `gleap-grant-<apiKey>`, so your help center or feedback portal on the same domain is signed in too.
    

## What a signed request unlocks

### Signed fields

The token's claims land on the contact: `email`, `name` and `phone` in the matching fields, `company` on the company, everything else as custom data. A signed email counts as verified. In the contact view each signed field shows a check, the contact carries a **Signed contact** badge, and each conversation shows **Verified** or **Unverified**. When a later token no longer carries a custom attribute, that value is removed.

### Protected fields

Choose which contact fields only signed updates may change: **Settings → Inbox & tickets → Data attributes**, tab **Contact**, switch **Only signed updates**. The user ID is always protected. When you create your first identity key, Gleap protects email, phone, company ID, company name and the billing and shop IDs for you.

A protected field changes only through a signed token, the [REST API](https://docs.gleap.ai/en/articles/708-rest.md), the dashboard, a CSV import, a workflow or Kai. A request from the app or the Messenger without a valid token leaves it unchanged and returns no error. An empty protected field may also be filled by the request that creates the contact. Leads (contacts with an email but no user ID) only have the user ID protected, so what a visitor types into the widget form still lands.

### Unsigned users and their history

An identified user whose requests carry no valid grant is **unsigned**: anyone could claim that user ID. Gleap then limits what the session can read. The widget shows only conversations with activity in the last **14 days**; older ones stay in Gleap and your team still sees them. Unsigned answers never return protected values or signed data. Visitors and leads are never limited. A signed request, or [requiring a signature](#require-a-signature) on that platform, lifts the limit.

## Require a signature

**Settings → Security → Customer identity → Identity verification** has one **Require a signature** switch per platform (web, iOS, Android), with the week's signed logins next to each. With it on, Gleap refuses an identify without a valid token on that platform with `signature_required`. Visitors and leads are never refused.

Turn a platform on once every app version you support there sends a token. The platform is what the SDK reports, so turn on every platform you ship to: an unsigned client could otherwise claim to be an app on a platform you left open. Gleap refuses to turn a platform on while no key could sign for it.

## Rotate and test keys

**Rotate a key.** Create a new key, switch your backend to it, then **Retire** the old one. A retired key still verifies, so tokens already in your users' apps run out normally. When its **Last used** date stops moving, **Revoke** it. A revoked key never verifies again and ends the grants it signed. Revoke at once when a secret leaks.

**Scope a key.** A key for one platform verifies only sign-ins from that platform, so a leaked mobile key can't sign anyone in on the web.

**Test a token.** **Test a token** on the Security page checks a token without storing anything: which key matched, the claims, and what the token proves, or why it was refused. **Signed logins, last 7 days** counts signed sign-ins per platform.

## Refused tokens

A bad token is always refused with an error: an app that sends a token wants to be signed, so a broken one never passes as unsigned. The code is in the error body's `details.value`.

<table style="min-width: 100px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Code</p></th><th colspan="1" rowspan="1"><p>Status</p></th><th colspan="1" rowspan="1"><p>Why</p></th><th colspan="1" rowspan="1"><p>What the SDK does</p></th></tr><tr><td colspan="1" rowspan="1"><p><code>token_expired</code></p></td><td colspan="1" rowspan="1"><p>401</p></td><td colspan="1" rowspan="1"><p><code>exp</code> is in the past.</p></td><td colspan="1" rowspan="1"><p>Asks your provider once and retries.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>token_invalid</code></p></td><td colspan="1" rowspan="1"><p>401</p></td><td colspan="1" rowspan="1"><p>Wrong signature, unknown or revoked key, wrong shape. <code>details.reason</code> says which.</p></td><td colspan="1" rowspan="1"><p>Keeps the session and logs a warning.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>signature_required</code></p></td><td colspan="1" rowspan="1"><p>401</p></td><td colspan="1" rowspan="1"><p>The platform requires a signature and none was valid.</p></td><td colspan="1" rowspan="1"><p>Logs the code.</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>identity_mismatch</code></p></td><td colspan="1" rowspan="1"><p>400</p></td><td colspan="1" rowspan="1"><p>The token is for another user than the current session.</p></td><td colspan="1" rowspan="1"><p>Starts over and signs in again.</p></td></tr></tbody></table>

`details.reason` for `token_invalid`: `key_unknown` (no such key, or revoked), `no_key_matched` (no key verifies the signature), `user_mismatch` (the token's user ID differs from the identify), `key_platform` (the key is scoped to another platform) or `malformed` (not a valid JWT, or no `userId`).

Call `clearIdentity` before you sign in another user and `identity_mismatch` never appears.

## Tools that act for the customer

**Settings → Security → Customer identity → Customer authentication** decides, per channel and audience, when a customer counts as **verified** for tools that read or change their data: billing, shop, Kai databases, and API and MCP tools.

-   **Signed login**: the conversation runs on a signed request. Only the Messenger has one.
    
-   **Email code**: the customer confirms a 6-digit code sent to the email on file (3 tries, valid 10 minutes). On the Messenger through the **Verify email** card, on email and the chat apps by replying with the code.
    

By default, identified Messenger users count as verified with a signed login or the email code, and everyone else confirms the code. Each tool has a **Verified customers only** switch (always on for billing and shop tools). In tools, `{{session.verifiedEmail}}` holds the proven email, `{{session.signedClaims.<name>}}` a signed claim, and `{{session.authTokens.<name>}}` a token from `setAuthTokens` (signed customers only). In workflows, `{{conversation.verification}}` is `signed_identity`, `verified_email` or `unverified`, and the **Customer verification** condition branches on it.

Conversations your backend creates through the server-to-server API count as signed.

## Help center, feedback portal and ticket links

They use the same sign-in as the widget, with no second login.

-   On your own domain, they read the grant cookie the JavaScript SDK set.
    
-   Elsewhere, hand the sign-in over: `Gleap.getIdentity()` returns `{ gleapId, gleapHash, grant }`. See [Sign visitors in to your help center](https://help.gleap.ai/en/articles/16751994-sign-visitors-in-to-your-help-center).
    
-   **Single sign-on.** The `sso` endpoints accept an identity token signed with your identity key, with `aud: "sso"` and an `exp` at most 10 minutes ahead. Each SSO token works once. Sign a separate token for SSO: `identify` refuses a token with `aud: "sso"`.
    
-   A session without a grant counts as logged out: it sees no restricted content and goes to your login URL.
    

## Merge contacts

The same person can end up as several contacts: an email to your support address, a website chat before login, the app login. Contacts with their own user ID are never merged into each other.

<table style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Case</p></th><th colspan="1" rowspan="1"><p>How</p></th></tr><tr><td colspan="1" rowspan="1"><p>A visitor chats, then logs in on the same device</p></td><td colspan="1" rowspan="1"><p>Automatic.</p></td></tr><tr><td colspan="1" rowspan="1"><p>The user logs in with a signed email</p></td><td colspan="1" rowspan="1"><p>Turn on <strong>Merge contacts by email</strong> on the Security page. Contacts without a user ID that have this email join the user.</p></td></tr><tr><td colspan="1" rowspan="1"><p>Your server creates the user</p></td><td colspan="1" rowspan="1"><p><code>POST /admin/identify</code> with the user ID and email takes over the contact that only has this email.</p></td></tr><tr><td colspan="1" rowspan="1"><p>Your server decides</p></td><td colspan="1" rowspan="1"><p><code>POST /admin/contacts/merge</code>, see the <a target="_blank" rel="noopener noreferrer nofollow" class="linktype-link link" data-linktype="link" href="https://docs.gleap.ai/en/articles/708-rest.md">REST API</a>.</p></td></tr></tbody></table>

When a contact without a user ID writes with the email of an identified user, the inbox shows a warning that the message may not come from that user.

## Shopify and WooCommerce

With the Gleap Shopify app or the WooCommerce plugin there's nothing to do: Gleap signs the storefront identities itself, so logged-in shop customers count as signed.

## Related

- [Authenticated conversation files](https://docs.gleap.ai/en/articles/710-authenticated-conversation-files.md)
- [Data regions](https://docs.gleap.ai/en/articles/711-data-regions.md)
- [Connect the Gleap MCP](https://docs.gleap.ai/en/articles/712-connect-the-gleap-mcp.md)
- [Plans and AI pricing](https://docs.gleap.ai/en/articles/713-plans-and-ai-pricing.md)
- [Screenshots, recordings and logs](https://docs.gleap.ai/en/articles/714-screenshots-recordings-and-logs.md)
