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).
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.
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.
How it works
Your server signs a token for the signed-in user with HS256 and your shared secret.
Your page passes the first token to the widget in the
jwtoption, and a function that gets new ones inonAuthExpired.The widget hands the first token to its frame in the address's fragment (
#jwt=), which browsers don't send to servers.Every request the widget makes carries its own token in an
Authorization: Bearerheader. Tokens work once, so before each request after the first, the widget callsonAuthExpiredfor a new one.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. For the widget, three of them matter most:
A new
jtifor every token. A random value, such as a UUID. The widget asks for a new token for every request.Times in seconds.
iatandexpare 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 anexp5 minutes afteriat.
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
onAuthExpiredto return a token, then sends the request without one, and it's refused.Errors are ignored. If
onAuthExpiredthrows 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.
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,emailorname.iatandexpare 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.onAuthExpiredmust return a new token every time.issoraudis 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 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.
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
jtiand anexp5 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.