# Sign readers into the widget with a JWT

_Category: Widget and embedding_

Show your private help center inside your product, in the widget, to users who are already signed in to it. Your server signs a short-lived token for each user, your page hands the tokens to the widget, and the widget shows them what a signed-in reader may read. There's no second login and no cookie involved.

**Plans:** Catalyst, for setting up single sign-on · **Who:** Owners and Admins save the shared secret, your developers connect the widget

## Before you start

- **The shared secret.** Set up JWT single sign-on under **Settings** → **Single sign-on** and copy the **Shared secret**. See [Sign readers in with your own login (JWT SSO)](https://self.helpcenter.io/content/jwt-sso).
- **A private help center.** On a public help center, the widget shows everything that's public without a token. See [Control who can see your help center](https://self.helpcenter.io/content/who-can-see-your-help-center).
- **A server that knows who is signed in.** Tokens are signed there, never in the browser, because signing needs the secret.
- **The widget on your page.** See [Widget installation and options](https://developers.helpcenter.io/content/widget-options).

## How it works

1. Your server signs a token for the signed-in user with HS256 and your shared secret.
2. Your page passes the first token to the widget in the `jwt` option, and a function that gets new ones in `onAuthExpired`.
3. The widget hands the first token to its frame in the address's fragment (`#jwt=`), which browsers don't send to servers.
4. Every request the widget makes carries its own token in an `Authorization: Bearer` header. Tokens work once, so before each request after the first, the widget calls `onAuthExpired` for a new one.
5. HelpCenter.io checks each token and answers as that reader. The first valid token for a person creates their reader record.

Sign-in in the widget keeps no session and uses no cookies, so browsers that block third-party cookies don't affect it.

## Step 1: Sign tokens on your server

A widget token is a standard HS256 JWT, signed with the shared secret exactly as your settings show it. It needs `jti`, `iat`, `exp`, `email` and `name`, and should carry `external_id`, your own user ID. If your settings have an **Issuer** or an **Audience**, it also needs `iss` or `aud` with the same value. Every claim and check is in [JWT claims and validation rules](https://developers.helpcenter.io/content/jwt-claims). For the widget, three of them matter most:

- **A new `jti` for every token.** A random value, such as a UUID. The widget asks for a new token for every request.
- **Times in seconds.** `iat` and `exp` are Unix timestamps in seconds, not milliseconds.
- **Fresh tokens.** The widget accepts a token up to **Token TTL** seconds after its `iat`, plus 30 seconds. Token TTL is in your settings: 300 by default and recommended. Sign each token when the widget asks for it, with an `exp` 5 minutes after `iat`.

Each program below serves `GET /helpcenter-token`, which returns a new token for the signed-in user as `{"jwt": "…"}`. They read the secret from the `HELPCENTER_SSO_SECRET` environment variable and use only the language's standard library. In your app, add the same route to the server that has your users' sessions, and replace `currentUser` with your session lookup.

```
// HelpCenter.io widget tokens for your signed-in users (Node.js 18+, no dependencies).
const crypto = require('node:crypto');
const http = require('node:http');

// Settings > Single sign-on > Shared secret, exactly as shown. Don't base64-decode it.
const SHARED_SECRET = process.env.HELPCENTER_SSO_SECRET;

function base64url(value) {
  return Buffer.from(value).toString('base64url');
}

function helpCenterToken(user) {
  const now = Math.floor(Date.now() / 1000); // seconds, not milliseconds
  const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const payload = base64url(JSON.stringify({
    jti: crypto.randomUUID(), // a new one for every token
    iat: now,
    exp: now + 300,
    email: user.email,
    name: user.name,
    external_id: String(user.id),
  }));
  const signature = crypto
    .createHmac('sha256', SHARED_SECRET)
    .update(`${header}.${payload}`)
    .digest('base64url');
  return `${header}.${payload}.${signature}`;
}

// Replace with your own session lookup. Return null when nobody is signed in.
function currentUser(req) {
  return { id: 4815, email: 'jane.doe@example.com', name: 'Jane Doe' };
}

http.createServer((req, res) => {
  if (req.method !== 'GET' || req.url !== '/helpcenter-token') {
    res.writeHead(404).end();
    return;
  }
  const user = currentUser(req);
  if (!user) {
    res.writeHead(401).end();
    return;
  }
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
  res.end(JSON.stringify({ jwt: helpCenterToken(user) }));
}).listen(process.env.PORT || 3000);
```

```
<?php
// helpcenter-token.php: a HelpCenter.io widget token for the signed-in user (PHP 8+).

function base64url(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function help_center_token(array $user): string
{
    $now = time(); // seconds
    $header = base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $payload = base64url(json_encode([
        'jti' => bin2hex(random_bytes(16)), // a new one for every token
        'iat' => $now,
        'exp' => $now + 300,
        'email' => $user['email'],
        'name' => $user['name'],
        'external_id' => (string) $user['id'],
    ]));
    // Settings > Single sign-on > Shared secret, exactly as shown. Don't base64-decode it.
    $secret = getenv('HELPCENTER_SSO_SECRET');
    $signature = base64url(hash_hmac('sha256', "$header.$payload", $secret, true));

    return "$header.$payload.$signature";
}

// Replace with your own session lookup. Return null when nobody is signed in.
function current_user(): ?array
{
    return ['id' => 4815, 'email' => 'jane.doe@example.com', 'name' => 'Jane Doe'];
}

$user = current_user();
if ($user === null) {
    http_response_code(401);
    exit;
}

header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode(['jwt' => help_center_token($user)]);
```

```
# HelpCenter.io widget tokens for your signed-in users (Python 3.8+, standard library).
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from wsgiref.simple_server import make_server

# Settings > Single sign-on > Shared secret, exactly as shown. Don't base64-decode it.
SHARED_SECRET = os.environ["HELPCENTER_SSO_SECRET"]

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

def help_center_token(user: dict) -> str:
    now = int(time.time())  # seconds
    header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    payload = b64url(json.dumps({
        "jti": secrets.token_hex(16),  # a new one for every token
        "iat": now,
        "exp": now + 300,
        "email": user["email"],
        "name": user["name"],
        "external_id": str(user["id"]),
    }).encode())
    signing_input = f"{header}.{payload}".encode()
    signature = hmac.new(SHARED_SECRET.encode(), signing_input, hashlib.sha256).digest()
    return f"{header}.{payload}.{b64url(signature)}"

def current_user(environ):
    """Replace with your own session lookup. Return None when nobody is signed in."""
    return {"id": 4815, "email": "jane.doe@example.com", "name": "Jane Doe"}

def app(environ, start_response):
    if environ["REQUEST_METHOD"] != "GET" or environ["PATH_INFO"] != "/helpcenter-token":
        start_response("404 Not Found", [])
        return [b""]
    user = current_user(environ)
    if user is None:
        start_response("401 Unauthorized", [])
        return [b""]
    body = json.dumps({"jwt": help_center_token(user)}).encode()
    headers = [("Content-Type", "application/json"), ("Cache-Control", "no-store")]
    start_response("200 OK", headers)
    return [body]

if __name__ == "__main__":
    make_server("127.0.0.1", int(os.environ.get("PORT", "3000")), app).serve_forever()
```

To try one on your computer, start it with the secret in the environment. The Node.js and Python programs listen on port 3000; the PHP one is served by PHP's built-in server:

```
HELPCENTER_SSO_SECRET='your shared secret' node token-server.js
HELPCENTER_SSO_SECRET='your shared secret' php -S 127.0.0.1:3000 helpcenter-token.php
HELPCENTER_SSO_SECRET='your shared secret' python3 token_server.py
```

The endpoint returns a token to anyone who holds your user's session, so keep it behind your normal sign-in, on your own domain, and answer `401` when nobody is signed in. `Cache-Control: no-store` keeps tokens out of caches.

## Step 2: Hand the tokens to the widget

On the pages where the widget runs, get the first token from your endpoint, then load the widget with it. `onAuthExpired` gets every token after that from the same endpoint:

```
<script>
  async function helpCenterToken() {
    const response = await fetch('/helpcenter-token', { credentials: 'same-origin' });
    if (!response.ok) {
      throw new Error(`Token request failed: ${response.status}`);
    }
    const { jwt } = await response.json();
    return jwt;
  }

  helpCenterToken().then((jwt) => {
    window.hcOptions = {
      app_id: 'YOUR_WIDGET_ID',
      jwt: jwt,
      onAuthExpired: helpCenterToken,
    };
    const s = document.createElement('script');
    s.src = 'https://helpcenter.io/js/init.js';
    s.async = true;
    document.body.appendChild(s);
  });
</script>
```

If your pages are rendered on your server, you can also write the first token into the page, as `jwt: '…'`, instead of fetching it.

What to expect:

- **Several token requests right away.** When the widget loads it makes a few requests, and each one after the first calls `onAuthExpired`. After that, every search, list or article the visitor opens is one more call. Keep your endpoint fast.
- **An 8-second limit.** The widget waits up to 8 seconds for `onAuthExpired` to return a token, then sends the request without one, and it's refused.
- **Errors are ignored.** If `onAuthExpired` throws or its Promise rejects, the widget waits out the 8 seconds the same way.

Pass both options. With `jwt` and no `onAuthExpired`, only the widget's first request is signed in: every later one waits 8 seconds and is refused. With `onAuthExpired` and no `jwt`, some of the widget's first requests fail, and its lists can stay empty.

### setJwt

`window.hcWidget.setJwt(token)` hands the widget a token outside that exchange. The widget uses it only when it has never had a token, for its next request, or when a request is waiting for one; otherwise it discards the token. It doesn't reload what the widget shows. When a user signs in or out while the page is open, reload the page, so the widget starts again with the right token, or with none.

## What signed-in readers see

A reader signed in with a token sees your help center's published articles in the widget: its home screen, categories, search and articles. Categories for team members only, and categories or articles shared with selected team members, stay hidden. Signing in doesn't give access to your dashboard, and a `role` claim is ignored. What a token does beyond the widget is in [How JWT single sign-on works](https://developers.helpcenter.io/content/how-jwt-sso-works).

Visitors without a valid token see the widget's search box and no articles. For about 8 seconds the widget shows "Reconnecting your secure session…" while it retries.

## When a token is rejected

A widget request with a missing or rejected token answers `403 Forbidden`:

```
{
  "status": "error",
  "message": "This help center requires authentication.",
  "code": "SITE_AUTH_REQUIRED"
}
```

The widget then shows "Reconnecting your secure session…", asks `onAuthExpired` for a new token and retries the request once. HelpCenter.io doesn't say why it refused a token. The usual causes:

- It isn't signed with HS256 and the shared secret as your settings show it, for example with the secret decoded from base64 first.
- A required claim is missing or empty: `jti`, `iat`, `exp`, `email` or `name`.
- `iat` and `exp` are in milliseconds, or your server's clock is more than 30 seconds off.
- The token is older than Token TTL plus 30 seconds, counted from `iat`. Sign tokens when the widget asks for them, not ahead of time.
- Its `jti`, or the whole token, was used before. `onAuthExpired` must return a new token every time.
- `iss` or `aud` is missing or different while your settings have an Issuer or an Audience.

Send `aud` as one string, never a list: a list makes the request fail with `500 Internal Server Error` instead of `403`. The token checker in [Troubleshoot single sign-on](https://developers.helpcenter.io/content/troubleshoot-sso) applies the same rules and names the one a token breaks.

A `403` with the code `SITE_IP_RESTRICTED` is different: your help center's IP allow-list refused the visitor's network, and no token changes that. See [Content Security Policy and allowed origins](https://developers.helpcenter.io/content/content-security-policy).

## Security notes

- **The secret stays on your server.** Anyone with it can sign in as any reader. Never put it in browser code, and never sign tokens in the browser.
- **The token endpoint follows your sign-in.** It returns tokens only for the user of the current session.
- **Tokens are short-lived and single use.** Sign each one with a new `jti` and an `exp` 5 minutes ahead.
- **The first token stays out of server logs.** It reaches the widget in the frame address's fragment, which browsers don't send to servers, and the widget removes it from the address when it loads. Later tokens reach the widget as messages from your page.

## Related

- [Build your Login URL](https://developers.helpcenter.io/content/build-your-login-url)
- [JWT claims and validation rules](https://developers.helpcenter.io/content/jwt-claims)
- [Widget installation and options](https://developers.helpcenter.io/content/widget-options)
- [Widget JavaScript API](https://developers.helpcenter.io/content/widget-javascript-api)
