Find what your readers see, or what you see while testing, and the entry below gives the cause and the fix. HelpCenter.io doesn't explain why it rejects a token, so start with the token checker when a sign-in fails: it applies the same rules and names the one a token breaks.
Check a token
Copy a token from your Login URL's redirect (everything after jwt= in the /sso/jwt address) and run the checker with your shared secret. It decodes the token, verifies the signature and applies HelpCenter.io's rules. It uses only the standard library.
// Check a HelpCenter.io single sign-on token the way HelpCenter.io checks it.
// Usage: node check-token.js <token>
// Reads HELPCENTER_SSO_SECRET, plus HELPCENTER_SSO_ISSUER and HELPCENTER_SSO_AUDIENCE
// when your settings have an Issuer or an Audience.
const crypto = require('node:crypto');
const SKEW = 30; // seconds of clock difference HelpCenter.io allows
const token = process.argv[2] || '';
const secret = process.env.HELPCENTER_SSO_SECRET || '';
const [h, p, s] = token.split('.');
const decode = (part) => {
try {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
} catch {
return null;
}
};
const header = decode(h || '');
const claims = decode(p || '');
if (token.split('.').length !== 3 || !header || !claims) {
console.log('Not a JWT: expected three dot-separated parts, the first two base64url JSON.');
process.exit(1);
}
console.log('Header:', JSON.stringify(header));
console.log('Claims:', JSON.stringify(claims, null, 2));
const problems = [];
const isSet = (v) => v !== undefined && v !== null && v !== '';
if (header.alg !== 'HS256') problems.push(`alg is ${header.alg}: only HS256 is accepted`);
if (!secret) {
problems.push('HELPCENTER_SSO_SECRET is not set, so the signature was not checked');
} else if (crypto.createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url') !== s) {
problems.push('the signature does not match HELPCENTER_SSO_SECRET (sign with the secret as shown, not decoded)');
}
for (const name of ['jti', 'iat', 'exp', 'email', 'name']) {
if (!isSet(claims[name])) problems.push(`missing required claim: ${name}`);
}
for (const name of ['jti', 'email', 'name', 'external_id', 'iss', 'aud', 'lang', 'avatar_url']) {
if (claims[name] !== null && typeof claims[name] === 'object') problems.push(`${name} must be a string`);
}
const now = Math.floor(Date.now() / 1000);
for (const name of ['iat', 'nbf', 'exp']) {
if (!isSet(claims[name])) continue;
const t = Number(claims[name]);
if (!Number.isFinite(t)) problems.push(`${name} must be a number of seconds`);
else if (name === 'exp' && t <= now - SKEW) problems.push(`exp passed ${now - t} seconds ago`);
else if (name !== 'exp' && t > 1e11) problems.push(`${name} looks like milliseconds: use seconds`);
else if (name !== 'exp' && t > now + SKEW) problems.push(`${name} is ${t - now} seconds in the future`);
}
const issuer = process.env.HELPCENTER_SSO_ISSUER;
const audience = process.env.HELPCENTER_SSO_AUDIENCE;
const text = (v) => (v === undefined || v === null ? null : String(v));
if (issuer && text(claims.iss) !== issuer) problems.push(`iss must be exactly "${issuer}"`);
if (audience && text(claims.aud) !== audience) problems.push(`aud must be exactly "${audience}"`);
const long = (name, max) => typeof claims[name] === 'string' && claims[name].length > max;
if (long('email', 240)) problems.push('email is longer than 240 characters');
if (long('external_id', 140)) problems.push('external_id is longer than 140 characters');
if (long('lang', 10)) problems.push('lang is longer than 10 characters');
if (long('avatar_url', 255)) problems.push('avatar_url is longer than 255 characters');
if (problems.length) {
console.log('\nProblems:\n' + problems.map((x) => `- ${x}`).join('\n'));
process.exit(1);
}
console.log('\nNo problems found. For the Login URL flow, jti must also be the key HelpCenter.io sent.');
# Check a HelpCenter.io single sign-on token the way HelpCenter.io checks it.
# Usage: python3 check_token.py <token>
# Reads HELPCENTER_SSO_SECRET, plus HELPCENTER_SSO_ISSUER and HELPCENTER_SSO_AUDIENCE
# when your settings have an Issuer or an Audience.
import base64, hashlib, hmac, json, os, sys, time
SKEW = 30 # seconds of clock difference HelpCenter.io allows
token = sys.argv[1] if len(sys.argv) > 1 else ""
secret = os.environ.get("HELPCENTER_SSO_SECRET", "")
parts = token.split(".")
def decode(part):
try:
return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
except ValueError:
return None
header = decode(parts[0]) if len(parts) == 3 else None
claims = decode(parts[1]) if len(parts) == 3 else None
if not isinstance(header, dict) or not isinstance(claims, dict):
print("Not a JWT: expected three dot-separated parts, the first two base64url JSON.")
sys.exit(1)
print("Header:", json.dumps(header))
print("Claims:", json.dumps(claims, indent=2, ensure_ascii=False))
problems = []
is_set = lambda v: v is not None and v != ""
if header.get("alg") != "HS256":
problems.append(f"alg is {header.get('alg')}: only HS256 is accepted")
if not secret:
problems.append("HELPCENTER_SSO_SECRET is not set, so the signature was not checked")
else:
digest = hmac.new(secret.encode(), f"{parts[0]}.{parts[1]}".encode(), hashlib.sha256).digest()
if base64.urlsafe_b64encode(digest).rstrip(b"=").decode() != parts[2]:
problems.append("the signature does not match HELPCENTER_SSO_SECRET (sign with the secret as shown, not decoded)")
for name in ["jti", "iat", "exp", "email", "name"]:
if not is_set(claims.get(name)):
problems.append(f"missing required claim: {name}")
for name in ["jti", "email", "name", "external_id", "iss", "aud", "lang", "avatar_url"]:
if isinstance(claims.get(name), (dict, list)):
problems.append(f"{name} must be a string")
now = int(time.time())
for name in ["iat", "nbf", "exp"]:
if not is_set(claims.get(name)):
continue
try:
t = float(claims[name])
except (TypeError, ValueError):
problems.append(f"{name} must be a number of seconds")
continue
if name == "exp" and t <= now - SKEW:
problems.append(f"exp passed {now - int(t)} seconds ago")
elif name != "exp" and t > 1e11:
problems.append(f"{name} looks like milliseconds: use seconds")
elif name != "exp" and t > now + SKEW:
problems.append(f"{name} is {int(t) - now} seconds in the future")
issuer = os.environ.get("HELPCENTER_SSO_ISSUER")
audience = os.environ.get("HELPCENTER_SSO_AUDIENCE")
text = lambda v: None if v is None else str(v)
if issuer and text(claims.get("iss")) != issuer:
problems.append(f'iss must be exactly "{issuer}"')
if audience and text(claims.get("aud")) != audience:
problems.append(f'aud must be exactly "{audience}"')
too_long = lambda name, most: isinstance(claims.get(name), str) and len(claims[name]) > most
for name, most in [("email", 240), ("external_id", 140), ("lang", 10), ("avatar_url", 255)]:
if too_long(name, most):
problems.append(f"{name} is longer than {most} characters")
if problems:
print("\nProblems:\n" + "\n".join(f"- {p}" for p in problems))
sys.exit(1)
print("\nNo problems found. For the Login URL flow, jti must also be the key HelpCenter.io sent.")
<?php
// Check a HelpCenter.io single sign-on token the way HelpCenter.io checks it.
// Usage: php check-token.php <token>
// Reads HELPCENTER_SSO_SECRET, plus HELPCENTER_SSO_ISSUER and HELPCENTER_SSO_AUDIENCE
// when your settings have an Issuer or an Audience.
const SKEW = 30; // seconds of clock difference HelpCenter.io allows
$token = $argv[1] ?? '';
$secret = (string) getenv('HELPCENTER_SSO_SECRET');
$parts = explode('.', $token);
function decode_part(string $part): ?array
{
$json = base64_decode(strtr($part, '-_', '+/'), true);
$value = $json === false ? null : json_decode($json, true);
return is_array($value) ? $value : null;
}
$header = count($parts) === 3 ? decode_part($parts[0]) : null;
$claims = count($parts) === 3 ? decode_part($parts[1]) : null;
if ($header === null || $claims === null) {
echo "Not a JWT: expected three dot-separated parts, the first two base64url JSON.\n";
exit(1);
}
echo 'Header: '.json_encode($header)."\n";
echo 'Claims: '.json_encode($claims, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n";
$problems = [];
$isSet = fn ($v) => $v !== null && $v !== '';
if (($header['alg'] ?? null) !== 'HS256') {
$problems[] = 'alg is '.($header['alg'] ?? 'missing').': only HS256 is accepted';
}
if ($secret === '') {
$problems[] = 'HELPCENTER_SSO_SECRET is not set, so the signature was not checked';
} else {
$expected = rtrim(strtr(base64_encode(hash_hmac('sha256', $parts[0].'.'.$parts[1], $secret, true)), '+/', '-_'), '=');
if (! hash_equals($expected, $parts[2])) {
$problems[] = 'the signature does not match HELPCENTER_SSO_SECRET (sign with the secret as shown, not decoded)';
}
}
foreach (['jti', 'iat', 'exp', 'email', 'name'] as $name) {
if (! $isSet($claims[$name] ?? null)) {
$problems[] = "missing required claim: $name";
}
}
foreach (['jti', 'email', 'name', 'external_id', 'iss', 'aud', 'lang', 'avatar_url'] as $name) {
if (is_array($claims[$name] ?? null)) {
$problems[] = "$name must be a string";
}
}
$now = time();
foreach (['iat', 'nbf', 'exp'] as $name) {
if (! $isSet($claims[$name] ?? null)) {
continue;
}
if (! is_numeric($claims[$name])) {
$problems[] = "$name must be a number of seconds";
continue;
}
$t = (float) $claims[$name];
if ($name === 'exp' && $t <= $now - SKEW) {
$problems[] = 'exp passed '.($now - (int) $t).' seconds ago';
} elseif ($name !== 'exp' && $t > 1e11) {
$problems[] = "$name looks like milliseconds: use seconds";
} elseif ($name !== 'exp' && $t > $now + SKEW) {
$problems[] = "$name is ".((int) $t - $now).' seconds in the future';
}
}
$issuer = (string) getenv('HELPCENTER_SSO_ISSUER');
$audience = (string) getenv('HELPCENTER_SSO_AUDIENCE');
$text = fn ($v) => $v === null ? null : (is_scalar($v) ? (string) $v : $v);
if ($issuer !== '' && $text($claims['iss'] ?? null) !== $issuer) {
$problems[] = "iss must be exactly \"$issuer\"";
}
if ($audience !== '' && $text($claims['aud'] ?? null) !== $audience) {
$problems[] = "aud must be exactly \"$audience\"";
}
foreach (['email' => 240, 'external_id' => 140, 'lang' => 10, 'avatar_url' => 255] as $name => $most) {
if (is_string($claims[$name] ?? null) && mb_strlen($claims[$name]) > $most) {
$problems[] = "$name is longer than $most characters";
}
}
if ($problems) {
echo "\nProblems:\n- ".implode("\n- ", $problems)."\n";
exit(1);
}
echo "\nNo problems found. For the Login URL flow, jti must also be the key HelpCenter.io sent.\n";
Run it with the secret in the environment. Add HELPCENTER_SSO_ISSUER and HELPCENTER_SSO_AUDIENCE when your settings have an Issuer or an Audience.
export HELPCENTER_SSO_SECRET='the Shared secret from your settings'
node check-token.js 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
For a token whose iat and exp are in milliseconds, it prints:
Header: {"alg":"HS256","typ":"JWT"}
Claims: {
"jti": "kT9wQ48213pZ2mV",
"iat": 1767225600000,
"exp": 1767225900000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"external_id": "4815"
}
Problems:
- iat looks like milliseconds: use seconds
It exits with 0 when it finds nothing and 1 when it finds a problem. It can't tell whether a jti is an unused key: that's the first thing to check when readers land on the HelpCenter.io sign-in page (see below).
Errors readers see
"Invalid SSO token."
The reader sees a blank page with the text "Invalid SSO token." at your help center's /sso/jwt address, with the status 401 Unauthorized. HelpCenter.io rejected the token for one of these reasons:
The signature doesn't match. The token isn't signed with the secret saved in your settings: a different secret, the previous one after you changed it, another help center's, or the saved secret decoded from base64 before signing.
The algorithm isn't HS256.
A required claim is missing or empty:
jti,iat,exp,emailorname.subdoesn't count asemail.iatornbfis more than 30 seconds ahead of HelpCenter.io's clock: usually milliseconds instead of seconds, or a server clock that runs fast.exppassed 30 seconds ago or more: the token was signed too early (for example before your login form) or your clock runs slow.issoraudis missing or different while your settings have an Issuer or Audience. Letter case counts.
Run the token through the checker, fix what it reports, and keep your servers' clocks in sync with NTP.
"Missing jwt parameter."
The reader sees this text with the status 400 Bad Request. Your endpoint redirected to /sso/jwt without ?jwt=<token>, or put the token in a parameter with another name.
"SSO is not properly configured."
The reader sees this text with the status 500 Internal Server Error. The help center that received the token has no single sign-on setup. Usually your redirect points at the wrong help center, for example a test one: check the address in your code. Otherwise, save the settings in Settings → Single sign-on.
"Whoops, something crashed."
HelpCenter.io's error page, with the status 500 Internal Server Error. The token has a claim of the wrong type, such as aud as a list or name as an object, or it's a reader's first sign-in and external_id, lang or avatar_url is too long. Send strings within the limits in JWT claims and validation rules. The checker flags all of these.
"Redirected you too many times"
The browser gives up with a too-many-redirects error (ERR_TOO_MANY_REDIRECTS in Chrome). The causes are under Redirect loops, below.
Readers land in the wrong place
The HelpCenter.io sign-in page, after your login
Your Login URL sends the reader back, and they land on HelpCenter.io's "Sign in to your account" page instead of your help center. The token itself was valid, but:
Its
jtiisn't thekeyfrom this request: a random value, a fixed test value, or a token signed for the widget.The key was already used: the token was signed for an earlier request, for example one your endpoint cached.
The token went to another help center than the one that sent the key.
emailis longer than 240 characters.
Sign a new token with the key of every request, send it to the help center that sent the key, and have the reader start again from the help center.
The HelpCenter.io sign-in page, instead of your login
The reader's IP address isn't on your IP allow-list. The allow-list is checked before single sign-on, so a token doesn't help. See Restrict access by IP address.
No single sign-on setup is saved. A private help center without one sends visitors to the HelpCenter.io sign-in page. Save a Login URL and Shared secret with Save JWT settings.
A password page instead of your login
The help center's visibility is Password. It asks for its password and never sends visitors to your Login URL. Switch it to Private. See Control who can see your help center.
No sign-in at all
The help center is Public. Anyone can read it, so nobody is sent to your Login URL.
Your endpoint is missing key or subdomain
The Login URL contains a
#. HelpCenter.io adds its parameters after it, so they land in the fragment, which browsers don't send: a Login URL ofhttps://app.example.com/auth/helpcenter#startbecomeshttps://app.example.com/auth/helpcenter#start?subdomain=acme&key=kT9wQ48213pZ2mV.The Login URL contains a
?. HelpCenter.io adds a second?:?source=helpbecomes?source=help?subdomain=acme&key=…, and your own parameter swallowssubdomain.Your login page dropped the query string when it sent the reader back after they signed in.
Keep the Login URL a plain address, and bring readers back to it with its full query string.
Redirect loops
The reader is signed in to another HelpCenter.io account
The browser goes from your help center to your Login URL, to /sso/jwt, to /app/sites on your help center and back to your Login URL, over and over, until it gives up. If your Login URL shows a login form, the reader sees it again after every sign-in.
HelpCenter.io completes an SSO sign-in only in a browser that isn't signed in to HelpCenter.io. On helpcenter.io addresses, one sign-in covers the dashboard and every help center, so this happens to someone who uses the HelpCenter.io dashboard for another help center, or who signed in as a reader of another help center on helpcenter.io. Members of your own team aren't affected: they see your help center as team members.
Readers: sign out of the HelpCenter.io dashboard in that browser, or use a private window. A reader of another help center has no sign-out link, so clear the browser's cookies for
helpcenter.io.You: a help center on its own custom domain keeps its own sign-in, which a session on
helpcenter.iodoesn't reach. See Use your own domain.
The help center's trial or subscription has ended
The browser goes from your help center to your Login URL, to /sso/jwt, to /not-available and back to your Login URL, until it gives up. A help center that is offline can't complete sign-ins, and on a private help center the page that says so asks for sign-in too. Readers see the loop instead of that page.
Activate a plan to bring the help center back online. See What happens when a trial or subscription ends?
Your redirect goes to the helpcenter.io address of a custom-domain help center
Readers of a help center with a custom domain are always on the custom domain, and a sign-in completed at <subdomain>.helpcenter.io/sso/jwt doesn't sign them in there, so they're sent to your Login URL again. Redirect to the custom domain.
In an iframe, every page after the first goes back to your Login URL
The first page loaded with ?jwt= is signed in, then the next one sends the frame to your Login URL, and loops if your Login URL signs readers straight back in. Your page and the help center are on different sites, for example acme.helpcenter.io inside app.example.com, and browsers don't keep the help center's session cookie in such a frame.
Serve the help center from a custom domain on your own site, such as help.example.com inside app.example.com, or use the widget, which sends a token with every request. See Embed your help center in your app.
The widget stays signed out
The widget shows only what's open to everyone, and on a private help center its requests in the browser's network tab answer 403 Forbidden with the code SITE_AUTH_REQUIRED. Every widget request needs a valid token of its own:
The token fails one of the rules above: run it through the checker.
It's older than Token TTL plus 30 seconds, counted from
iat. Sign tokens when the widget asks for them, not ahead of time.It reuses a token or a
jti. YouronAuthExpiredfunction must return a new token, with a newjti, every time it's called.
See Sign readers into the widget with a JWT.
Changes over time
A reader shows up twice after an email change
The reader first signed in without external_id, so HelpCenter.io matches them by email only, and their new email created a second reader. external_id is saved only when a reader is created. Send it in every token from the first sign-in. See What if a reader's email changes on our side?
A reader's name or email is out of date
HelpCenter.io keeps the details from a reader's first sign-in, and later tokens don't update them. Their comments keep showing the first name.
Sign-ins fail right after you changed the secret
A help center has one secret at a time, and a new one applies as soon as you save it: tokens signed with the old secret fail from then on. Readers who are already signed in stay signed in. Save the new secret and deploy it to your servers at the same time. See Can I rotate the SSO shared secret without downtime?
Someone you removed from your app can still read the help center
A reader's session lasts until 24 hours pass without a visit. There's no sign-out, removing someone from your app doesn't end their session, and neither does a new secret. Their access ends when the session does.
Tokens expire before they arrive
The token was signed before a slow step, such as your login form, or its exp is too close to iat. Sign the token right before you redirect, give it an exp of iat plus 300, and keep your clock in sync. See How long should my SSO tokens live?
The settings don't save
The Single sign-on card shows the reason when Save JWT settings fails:
Message | Fix |
|---|---|
"The config.shared secret field must be at least 64 characters." | Click Regenerate, or paste a secret of 64 characters or more. |
"The config.urls.login field must be a valid URL." | Enter the full address, starting with |
"The config.token ttl field must be at least 30." | Use a Token TTL from 30 to 86,400 seconds. |
"Single sign-on is not included in your current subscription plan. Upgrade to Catalyst to configure SSO." | A new setup needs the Catalyst plan. See Change your plan. |
You switched off Enable Single Sign-On, but readers still go to your login
The switch only hides the settings: it saves nothing, and your saved setup stays active. To stop sending readers to your Login URL, change the help center's visibility, or contact support to remove the setup.
Team members are sent to your Login URL on a custom domain
On a private help center with a custom domain and single sign-on, team members who open the help center are sent to your Login URL like everyone else. If your app signs them in, they read as SSO readers and don't see content for team members only. To open it as a team member, sign in to the HelpCenter.io dashboard first, then go to https://helpcenter.io/app/auth/custom-domain?d=help.example.com, with your custom domain in place of help.example.com.