Skip to main content

Why identity verification

Without identity verification, anyone can claim to be any user by sending an arbitrary userId to your widget. For internal tools and demos this is fine. For production B2B SaaS, a malicious user could impersonate a colleague and read their support history. Identity verification fixes this by requiring every chat call to include a JWT signed with your Identity Secret. The Halo backend verifies the signature; if it doesn’t match, the call is rejected with 403. The JWT can also carry trusted claims — email, name, role, plan, company_id — that Halo will use without a database lookup. This makes the AI more responsive and lets you prove identity even when the user record hasn’t been pushed to Halo yet.

Two keys, two roles

Halo generates two keys when you set up your project:
KeyPrefixWhere it goesPurpose
Widget keyab_live_...Frontend (browser)Identify your workspace — publishable, safe to expose
Identity secretha_secret_...Backend onlySign JWTs for identity verification
Copy your widget key from Setup > Install. Generate your identity secret under Settings > Security. Never confuse them — exposing the identity secret in client code defeats the entire system.

JWT payload

The JWT must include user_id and may include any additional claims:
{
  "user_id": "user_123",
  "email": "[email protected]",
  "name": "Jane Doe",
  "company_id": "company_456",
  "role": "admin",
  "plan": "pro",
  "exp": 1716242622
}
ClaimRequired?Notes
user_idRequiredMust match the userId passed to Halo from the frontend
expRecommendedStandard JWT expiration (Unix timestamp)
All other claimsOptionalUsed as trusted user data by the AI
Algorithm: HS256. The signing key is your Identity Secret (ha_secret_...).

Server-side: generate the JWT

Sign the JWT on your backend whenever you serve a page that loads the Halo widget. Pass the resulting token to your frontend (via the page HTML, an API endpoint, or session state).
const jwt = require("jsonwebtoken");

function generateUserToken(user, secretKey) {
  return jwt.sign(
    {
      user_id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
      company_id: user.companyId,
    },
    secretKey,
    { algorithm: "HS256", expiresIn: "24h" }
  );
}

const token = generateUserToken(currentUser, process.env.HALOAGENTS_SECRET_KEY);
If you expose JWT minting through an HTTP route (for getUserToken or SPA refresh), log errors in your catch blocks before returning 500. Empty catch {} blocks make production debugging much harder.
// Example route handler shape (adapt to your framework)
async function haloTokenHandler(request) {
  try {
    const user = await getAuthenticatedUser(request);
    if (!user) return respond({ error: "Unauthorized" }, 401);
    const token = generateUserToken(user, process.env.HALOAGENTS_SECRET_KEY);
    return respond({ token }, 200);
  } catch (error) {
    console.error("Failed to generate Halo token", error);
    return respond({ error: "Failed to generate token" }, 500);
  }
}

Client-side: pass the token

Pass the JWT as userToken when initializing Halo. Always include userTraits with at least name and email so users show up correctly in your inbox:
<script src="https://cdn.haloagents.ai/sdk/latest/haloagents.umd.js"></script>
<script>
  window.HaloAgents.init({
    orgId: "your-org-id",
    apiKey: "ab_live_xxxxxxxxxxxxxxxx",
    userId: "user_123",
    userToken: "YOUR_JWT_TOKEN",
    userTraits: {
      name: "Jane Doe",
      email: "[email protected]",
    },
  });
</script>
For server-rendered pages, you can inject the token directly:
<script>
  window.HaloAgents.init({
    orgId: "your-org-id",
    apiKey: "ab_live_xxxxxxxxxxxxxxxx",
    userId: "<?= $user->id ?>",
    userToken: "<?= $haloToken ?>",
    userTraits: {
      name: "<?= $user->name ?>",
      email: "<?= $user->email ?>",
    },
  });
</script>

Token refresh

JWTs expire. The SDK supports two refresh patterns: Provide an async callback that fetches a fresh JWT from your backend. The SDK calls it when the static userToken from init() is missing or near expiry (5-minute skew). The identity secret stays on your server.
window.HaloAgents.init({
  orgId: "your-org-id",
  apiKey: "ab_live_xxxxxxxxxxxxxxxx",
  userId: "user_123",
  userToken: initialJwtFromYourServer,
  getUserToken: async () => {
    const res = await fetch("/api/haloagents-token");
    const { token } = await res.json();
    return token;
  },
  onIdentityExpired: ({ reason, expiresAt, message }) => {
    // reason: "expired" | "missing" | "rejected"
    // Fires once; user-scoped requests pause until a fresh token arrives.
    refreshSessionAndReIdentify();
  },
});
When identity fails (expired, missing, or rejected with 403), the SDK stops sending known-expired JWTs and pauses user-scoped polling until getUserToken or identify(..., { userToken }) succeeds. Server-side enforce mode is unchanged.

Manual: identify() rotation

You can still rotate tokens yourself on session refresh:
const newToken = await fetch("/api/halo-token").then(r => r.json()).then(d => d.token);
HaloAgents.getInstance()?.identify(userId, traits, { userToken: newToken });
A common pattern: generate the JWT with exp matching your app’s session length (e.g. 24 hours) and refresh both at the same time.

Verification modes (dashboard)

After generating an identity secret under Settings > Security, choose how strictly Halo validates tokens:
ModeBehavior
OffNo verification
MonitorValidate when present; log warnings but allow requests. Default when you generate a secret
EnforceReject unverified authenticated requests with 403
Stay in monitor while integrating. The Security page tracks production health and unlocks enforce only after verified requests succeed in the last 24 hours with no failures.

Server-to-server calls

The same token applies to direct REST API calls from your backend. For example, POST /api/sdk/chat accepts user_token:
await fetch("https://api.haloagents.ai/api/sdk/chat", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer ab_live_xxxxxxxxxxxxxxxx",
  },
  body: JSON.stringify({
    user_id: "user_123",
    user_token: serverGeneratedJWT,
    message: "...",
  }),
});
If identity verification is enabled and a non-anonymous user_id is provided without a valid user_token, the request returns 403.

Best practices

  1. Always pass name and email in userTraits (during init) or via identify(). Without them, users show as “Anonymous” in tickets and conversations.
  2. Set exp to match your session length — 24 hours is a common default. Shorter is more secure.
  3. Refresh tokens with getUserToken (preferred) or identify(..., { userToken }) on session refresh.
  4. Wire onIdentityExpired so your app refreshes credentials when enforce mode rejects stale JWTs.
  5. Include trusted claims — the more claims (email, role, plan, company_id), the more personalized the AI without extra database lookups.
  6. Never expose the Identity Secret client-side — keep it in environment variables, never in your HTML or frontend bundle.

Where to go next

Identify Users

The basic identification flow before adding verification.

API Reference

Server-to-server calls with JWT.

Security Settings

Generate your identity secret and set verification mode.