Use this guide if you embed the HelpCenter.io Smart Widget inside an authenticated SaaS product and you want your end-users to see your private help-center content without going through a separate login.
How it works
Your application authenticates the user the way it always has.
When the user lands on a page where you embed the widget, your backend mints a short-lived JWT identifying them.
The host snippet hands the JWT to the widget on init.
The widget includes
Authorization: Bearer <jwt>on every call to HelpCenter.io.HelpCenter.io validates the signature against your shared secret, identifies the user, and serves articles only if
Site::visibilityallows.
No cookies are involved. No browser-level third-party cookie issues. No login screen.
One-time setup (HelpCenter.io dashboard)
Go to Settings → Security → JWT SSO.
Set your Login URL (used by the redirect-based flow if a user lands on the help-center site directly).
Click Generate to mint a shared secret (or paste your own — minimum 64 characters).
Optionally set an Issuer and Audience claim to enforce. If set, every JWT you sign must include matching
issandaudclaims or it is rejected.Optionally set a Token TTL (defaults to 300 seconds). The widget surface rejects any JWT whose
iatis older than this even ifexpis still in the future — defense in depth against leaked tokens.Save.
Backend: minting the JWT
HS256 only. Sign with the shared secret.
Required claims
Claim | Type | Notes |
|---|---|---|
| string | Unique per token. Used for replay protection. |
| string | Your issuer — typically your app's domain. Must match the configured Issuer if set. |
| int | Issued-at, Unix seconds. |
| int | Expiry, Unix seconds. Always required. Keep short — 5 minutes is typical. |
| string | The user's email. |
| string | The user's display name. |
Optional claims
Claim | Type | Notes |
|---|---|---|
| string | Your stable user ID. Recommended — lets us track the same user across email changes. |
| string | Two-letter language code. Defaults to the site's default language. |
| string | Public URL to the user's avatar. |
| object | Free-form. Surfaced on the User record for analytics. |
| string |
|
| string | Audience. Must match the configured Audience if set. |
Example (Node / jsonwebtoken)
const jwt = require('jsonwebtoken');
function mintHelpcenterJwt(user) {
return jwt.sign({
jti: `${user.id}.${Date.now()}.${Math.random().toString(36).slice(2)}`,
iss: 'app.yourcompany.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 300,
email: user.email,
name: user.fullName,
external_id: String(user.id),
role: 'viewer',
}, process.env.HELPCENTER_SHARED_SECRET, { algorithm: 'HS256' });
}
Example (PHP / firebase/php-jwt)
use Firebase\JWT\JWT;
function mint_helpcenter_jwt(User $user): string
{
return JWT::encode([
'jti' => $user->id . '.' . microtime(true),
'iss' => 'app.yourcompany.com',
'iat' => time(),
'exp' => time() + 300,
'email' => $user->email,
'name' => $user->name,
'external_id' => (string) $user->id,
'role' => 'viewer',
], config('helpcenter.shared_secret'), 'HS256');
}
Frontend: passing the JWT to the widget
If you already embed the widget, you have a snippet that looks like this:
<script>
window.hcOptions = {
app_id: 'YOUR_WIDGET_ID',
};
</script>
<script src="https://yourdomain.helpcenter.io/js/init_widget.js" async></script>
To add SSO, set jwt to the token your backend just minted, and define onAuthExpired so the widget can ask for a fresh one when the current one runs out:
<script>
window.hcOptions = {
app_id: 'YOUR_WIDGET_ID',
jwt: '{{ minted_jwt }}',
onAuthExpired: async function () {
const resp = await fetch('/helpcenter-token', { credentials: 'include' });
const json = await resp.json();
return json.jwt;
},
};
</script>
<script src="https://yourdomain.helpcenter.io/js/init_widget.js" async></script>
The onAuthExpired callback runs whenever the widget receives a 403 SITE_AUTH_REQUIRED response from the API. Return the new JWT (string) — the widget uses it immediately without reloading.
If you need to push a fresh token outside the expiry flow (e.g., user switched accounts), call window.hcWidget.setJwt(newJwt).
Security notes
TTL is your friend. Five minutes is a reasonable default. The shorter the TTL, the smaller the window if a token leaks.
Token is in the URL hash, not the query string. The widget snippet appends
#jwt=<token>to the iframe URL. Hashes are not sent to the server, so the token never lands in proxy or access logs. The widget JS reads it on load and immediately strips it from the URL.One use per
jtion the widget surface. A replayed token is rejected even if the signature andexpare still valid.Clock skew tolerance is 30 seconds on both
iatandexp. Sync your servers via NTP — anything more than 30 seconds of drift will cause sporadic rejections.
Failure modes
When the widget cannot authenticate, the API returns:
{
"status": "error",
"code": "SITE_AUTH_REQUIRED",
"message": "This help center requires authentication."
}
…with HTTP 403. The widget triggers onAuthExpired on the host page. If the host returns a fresh JWT, the widget transparently retries. If not, the widget renders an unauthenticated state (no articles visible).
For diagnosis, server-side logs include a widget_jwt.rejected event with a reason field:
Reason | What it means |
|---|---|
| Signature didn't verify. Wrong secret? |
| One of |
|
|
|
|
|
|
|
|
|
|
| This |
| The provisioned local user is suspended. |
What you don't have to do
You do NOT need to manage cookies on
embed.helpcenter.io. No third-party-cookie configuration. Safari ITP and Firefox don't interfere.You do NOT need to expose your shared secret to the browser — it stays on your backend.
You do NOT need to host a callback URL for HelpCenter.io to redirect to. The widget flow is pure header auth.
You do NOT need to pre-create users in the HelpCenter.io dashboard. The first successful JWT for a given email/external_id auto-provisions the user as a viewer of your help center.