Your Login URL is the endpoint in your app that signs readers in to your help center. HelpCenter.io sends a reader there with a one-time key, your endpoint checks who they are in your app, and it sends them back to the help center with a signed token.
Before you start
Single sign-on is set up in your dashboard under Settings → Single sign-on, with this endpoint's address as the Login URL and a Shared secret. See Sign readers in with your own login (JWT SSO). A new setup needs the Catalyst plan.
Your help center is set to Private. Only private help centers send visitors to the Login URL.
Your server has the shared secret in an environment variable,
HELPCENTER_SSO_SECRETin these examples. It never goes to the browser.Node.js 18 or later, PHP 8 or later, Python 3.8 or later, or Ruby 2.6 or later. The programs below use only the standard library.
What your endpoint receives
HelpCenter.io sends the reader's browser to your Login URL with two query parameters:
GET /auth/helpcenter?subdomain=acme&key=kT9wQ48213pZ2mV HTTP/1.1
Host: app.example.com
Parameter | What it is |
|---|---|
| Your help center's subdomain on helpcenter.io, even when readers use your custom domain. It tells you which help center sent the reader. Don't use it to build the address you redirect to. |
| A one-time value. Put it in the token's |
Nothing else is sent, and there's no return address: HelpCenter.io remembers the page the reader asked for and takes them there after sign-in.
The request comes from the reader's browser, not from HelpCenter.io's servers, so your endpoint only has to be reachable by your readers. It also receives anyone else who opens your private help center without a session, bots included, so send every visitor who isn't signed in to your login page.
What your endpoint does
Read
keyfrom the query string. If it's missing, answer400 Bad Request.Find the signed-in user in your own session. If nobody is signed in, send them to your login page and, once they have signed in, back to this same URL with the same
key.Sign a token with HS256 and the shared secret, with the claims
jti(thekey),iat(now, in seconds),exp(iatplus 300),email,nameandexternal_id(your stable user ID).Redirect with
302 Foundtohttps://<your help center>/sso/jwt?jwt=<token>. The help center's address is your custom domain if you have one, otherwise<subdomain>.helpcenter.io.
Your endpoint's answer looks like this:
HTTP/1.1 302 Found
Location: https://help.example.com/sso/jwt?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJrVDl3UTQ4MjEzcFoybVYi...
HelpCenter.io then checks the token, signs the reader in and sends them to the page they first asked for.
The code
Each program is complete and uses only the language's standard library. It serves the Login URL at /auth/helpcenter on port 3000. Replace the current user lookup with your own session and HELP_CENTER_URL with your help center's address, and set HELPCENTER_SSO_SECRET.
// HelpCenter.io single sign-on: your Login URL in Node.js 18+, no dependencies.
const crypto = require('node:crypto');
const http = require('node:http');
// Your help center's address: its custom domain, or https://<subdomain>.helpcenter.io.
// Write it here. Never build it from the `subdomain` query parameter.
const HELP_CENTER_URL = 'https://help.example.com';
// The Shared secret from Settings > Single sign-on, exactly as shown.
// Use it as-is: do not base64-decode it.
const SHARED_SECRET = process.env.HELPCENTER_SSO_SECRET;
function base64url(input) {
return Buffer.from(input).toString('base64url');
}
// Sign a set of claims with HS256.
function signHelpCenterToken(claims, secret) {
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = base64url(JSON.stringify(claims));
const signature = crypto
.createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url');
return `${header}.${payload}.${signature}`;
}
// jti: the `key` HelpCenter.io sent to your Login URL.
// For widget and iframe tokens, pass a new random value: crypto.randomUUID().
function helpCenterTokenFor(user, jti) {
const now = Math.floor(Date.now() / 1000); // seconds, not milliseconds
return signHelpCenterToken({
jti,
iat: now,
exp: now + 300,
email: user.email,
name: user.name,
external_id: String(user.id),
}, SHARED_SECRET);
}
// 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' };
}
// Your Login URL, for example https://app.example.com/auth/helpcenter.
// HelpCenter.io sends readers to /auth/helpcenter?subdomain=<subdomain>&key=<key>.
function loginUrl(req, res) {
const key = new URL(req.url, 'http://localhost').searchParams.get('key');
if (!key) {
res.writeHead(400).end('Missing key');
return;
}
const user = currentUser(req);
if (!user) {
// Not signed in to your app: show your login page, then come back
// to this same URL, with the same key.
res.writeHead(302, { Location: `/login?next=${encodeURIComponent(req.url)}` }).end();
return;
}
const token = helpCenterTokenFor(user, key);
const location = `${HELP_CENTER_URL}/sso/jwt?jwt=${encodeURIComponent(token)}`;
res.writeHead(302, { Location: location }).end();
}
http.createServer((req, res) => {
if (new URL(req.url, 'http://localhost').pathname === '/auth/helpcenter') {
loginUrl(req, res);
} else {
res.writeHead(404).end();
}
}).listen(process.env.PORT || 3000);
<?php
// HelpCenter.io single sign-on: your Login URL in PHP 8, no Composer packages.
// Your help center's address: its custom domain, or https://<subdomain>.helpcenter.io.
// Write it here. Never build it from the `subdomain` query parameter.
const HELP_CENTER_URL = 'https://help.example.com';
function base64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
// Sign a set of claims with HS256. $secret is the Shared secret from
// Settings > Single sign-on, exactly as shown: do not base64-decode it.
function sign_help_center_token(array $claims, string $secret): string
{
$header = base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64url(json_encode($claims));
$signature = base64url(hash_hmac('sha256', $header.'.'.$payload, $secret, true));
return $header.'.'.$payload.'.'.$signature;
}
// $jti: the `key` HelpCenter.io sent to your Login URL.
// For widget and iframe tokens, pass a new random value: bin2hex(random_bytes(16)).
function help_center_token_for(array $user, string $jti): string
{
$now = time(); // seconds
return sign_help_center_token([
'jti' => $jti,
'iat' => $now,
'exp' => $now + 300,
'email' => $user['email'],
'name' => $user['name'],
'external_id' => (string) $user['id'],
], getenv('HELPCENTER_SSO_SECRET'));
}
// 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'];
}
// Your Login URL, for example https://app.example.com/auth/helpcenter.
// HelpCenter.io sends readers to /auth/helpcenter?subdomain=<subdomain>&key=<key>.
$key = isset($_GET['key']) && is_string($_GET['key']) ? $_GET['key'] : '';
if ($key === '') {
http_response_code(400);
exit('Missing key');
}
$user = current_user();
if ($user === null) {
// Not signed in to your app: show your login page, then come back
// to this same URL, with the same key.
header('Location: /login?next='.rawurlencode($_SERVER['REQUEST_URI']), true, 302);
exit;
}
$token = help_center_token_for($user, $key);
header('Location: '.HELP_CENTER_URL.'/sso/jwt?jwt='.rawurlencode($token), true, 302);
# HelpCenter.io single sign-on: your Login URL in Python 3.8+, standard library only.
import base64
import hashlib
import hmac
import json
import os
import time
from urllib.parse import parse_qs, quote
# Your help center's address: its custom domain, or https://<subdomain>.helpcenter.io.
# Write it here. Never build it from the `subdomain` query parameter.
HELP_CENTER_URL = "https://help.example.com"
# The Shared secret from Settings > Single sign-on, exactly as shown.
# Use it as-is: do not base64-decode it.
SHARED_SECRET = os.environ["HELPCENTER_SSO_SECRET"]
def base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def sign_help_center_token(claims: dict, secret: str) -> str:
"""Sign a set of claims with HS256."""
header = base64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = base64url(json.dumps(claims).encode())
signing_input = f"{header}.{payload}".encode()
signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
return f"{header}.{payload}.{base64url(signature)}"
def help_center_token_for(user: dict, jti: str) -> str:
"""jti: the `key` HelpCenter.io sent to your Login URL.
For widget and iframe tokens, pass a new random value: secrets.token_hex(16)."""
now = int(time.time()) # seconds
return sign_help_center_token({
"jti": jti,
"iat": now,
"exp": now + 300,
"email": user["email"],
"name": user["name"],
"external_id": str(user["id"]),
}, SHARED_SECRET)
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 login_url(environ, start_response):
"""Your Login URL (a WSGI app), for example https://app.example.com/auth/helpcenter.
HelpCenter.io sends readers to /auth/helpcenter?subdomain=<subdomain>&key=<key>."""
key = parse_qs(environ.get("QUERY_STRING", "")).get("key", [""])[0]
if not key:
start_response("400 Bad Request", [("Content-Type", "text/plain")])
return [b"Missing key"]
user = current_user(environ)
if user is None:
# Not signed in to your app: show your login page, then come back
# to this same URL, with the same key.
back = environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "")
back += "?" + environ.get("QUERY_STRING", "")
start_response("302 Found", [("Location", "/login?next=" + quote(back, safe=""))])
return [b""]
token = help_center_token_for(user, key)
location = f"{HELP_CENTER_URL}/sso/jwt?jwt={quote(token, safe='')}"
start_response("302 Found", [("Location", location)])
return [b""]
if __name__ == "__main__":
from wsgiref.simple_server import make_server
port = int(os.environ.get("PORT", "3000"))
make_server("127.0.0.1", port, login_url).serve_forever()
# HelpCenter.io single sign-on: your Login URL as a Rack app, Ruby standard library only.
require 'json'
require 'openssl'
require 'uri'
module HelpCenterSso
# Your help center's address: its custom domain, or https://<subdomain>.helpcenter.io.
# Write it here. Never build it from the `subdomain` query parameter.
HELP_CENTER_URL = 'https://help.example.com'.freeze
module_function
def base64url(data)
[data].pack('m0').tr('+/', '-_').delete('=')
end
# Sign a set of claims with HS256. `secret` is the Shared secret from
# Settings > Single sign-on, exactly as shown: do not base64-decode it.
def sign(claims, secret)
header = base64url({ alg: 'HS256', typ: 'JWT' }.to_json)
payload = base64url(claims.to_json)
signature = OpenSSL::HMAC.digest('SHA256', secret, "#{header}.#{payload}")
"#{header}.#{payload}.#{base64url(signature)}"
end
# jti: the `key` HelpCenter.io sent to your Login URL.
# For widget and iframe tokens, pass a new random value: SecureRandom.uuid
# (after require 'securerandom').
def token_for(user, jti)
now = Time.now.to_i # seconds
sign({
jti: jti,
iat: now,
exp: now + 300,
email: user[:email],
name: user[:name],
external_id: user[:id].to_s
}, ENV.fetch('HELPCENTER_SSO_SECRET'))
end
def redirect_url(token)
"#{HELP_CENTER_URL}/sso/jwt?jwt=#{URI.encode_www_form_component(token)}"
end
end
# Replace with your own session lookup. Return nil when nobody is signed in.
def current_user(_env)
{ id: 4815, email: 'jane.doe@example.com', name: 'Jane Doe' }
end
# Your Login URL, for example https://app.example.com/auth/helpcenter.
# HelpCenter.io sends readers to /auth/helpcenter?subdomain=<subdomain>&key=<key>.
LOGIN_URL_APP = lambda do |env|
key = URI.decode_www_form(env['QUERY_STRING'].to_s).to_h['key'].to_s
return [400, { 'content-type' => 'text/plain' }, ['Missing key']] if key.empty?
user = current_user(env)
if user.nil?
# Not signed in to your app: show your login page, then come back
# to this same URL, with the same key.
back = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}?#{env['QUERY_STRING']}"
return [302, { 'location' => "/login?next=#{URI.encode_www_form_component(back)}" }, []]
end
token = HelpCenterSso.token_for(user, key)
[302, { 'location' => HelpCenterSso.redirect_url(token) }, []]
end
# Serve it with any Rack server. For example, next to this file, a config.ru with:
# require_relative 'login_url'
# map('/auth/helpcenter') { run LOGIN_URL_APP }
# then: gem install rackup webrick && rackup -p 3000
Decoded, the token the programs sign looks like this:
{
"jti": "kT9wQ48213pZ2mV",
"iat": 1767225600,
"exp": 1767225900,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"external_id": "4815"
}
Every claim, and how HelpCenter.io checks it, is in JWT claims and validation rules.
With a JWT library
If your app already uses a JWT library, sign the token with it and keep the rest of the program: replace base64url, the signing function and the token function with the code for your language.
// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
// jti: the `key` HelpCenter.io sent to your Login URL.
function helpCenterTokenFor(user, jti) {
const now = Math.floor(Date.now() / 1000); // seconds
return jwt.sign({
jti,
iat: now,
exp: now + 300,
email: user.email,
name: user.name,
external_id: String(user.id),
}, process.env.HELPCENTER_SSO_SECRET, { algorithm: 'HS256' });
}
// composer require firebase/php-jwt
require __DIR__.'/vendor/autoload.php';
use Firebase\JWT\JWT;
// $jti: the `key` HelpCenter.io sent to your Login URL.
function help_center_token_for(array $user, string $jti): string
{
$now = time(); // seconds
return JWT::encode([
'jti' => $jti,
'iat' => $now,
'exp' => $now + 300,
'email' => $user['email'],
'name' => $user['name'],
'external_id' => (string) $user['id'],
], getenv('HELPCENTER_SSO_SECRET'), 'HS256');
}
# pip install PyJWT
import os
import time
import jwt
def help_center_token_for(user: dict, jti: str) -> str:
"""jti: the `key` HelpCenter.io sent to your Login URL."""
now = int(time.time()) # seconds
return jwt.encode({
"jti": jti,
"iat": now,
"exp": now + 300,
"email": user["email"],
"name": user["name"],
"external_id": str(user["id"]),
}, os.environ["HELPCENTER_SSO_SECRET"], algorithm="HS256")
# gem install jwt
require 'jwt'
module HelpCenterSso
module_function
# jti: the `key` HelpCenter.io sent to your Login URL.
def token_for(user, jti)
now = Time.now.to_i # seconds
JWT.encode({
jti: jti,
iat: now,
exp: now + 300,
email: user[:email],
name: user[:name],
external_id: user[:id].to_s
}, ENV.fetch('HELPCENTER_SSO_SECRET'), 'HS256')
end
end
With any library, set the algorithm to HS256 explicitly, pass the shared secret as the plain string, and if you set an Audience, pass aud as one string, not a list. In a Rails controller, redirect with redirect_to url, allow_other_host: true: Rails 7 and later refuse to redirect to another host without it.
Rules that matter
jtiis thekey. A token with any otherjti, or with a key that was already used, sends the reader to the HelpCenter.io sign-in page instead of your help center. Sign a new token for every request to your Login URL, and never cache one.Seconds, not milliseconds.
iatandexpare Unix time in seconds.Date.now()in JavaScript returns milliseconds, and a token built from it fails with "Invalid SSO token."Use the secret exactly as shown. A generated secret looks like base64, but the string itself is the key. A token signed with its decoded bytes fails.
Write your help center's address into your code. Never build the redirect from the
subdomainparameter: a crafted link could make your endpoint send a valid token to another site. If one Login URL serves several help centers, looksubdomainup in your own list of help centers, each with its address and secret, and refuse values that aren't on it.Use your custom domain if you have one. Readers of a help center with a custom domain are always on that domain, and a sign-in at
<subdomain>.helpcenter.io/sso/jwtdoesn't sign them in there.Keep
?and#out of the Login URL. HelpCenter.io adds?subdomain=…&key=…to the end of it as text. A Login URL with its own query string gets a second?, and one with a#hides both parameters in the fragment, where your endpoint never sees them.Keep claims as strings.
audmust be one string, andemailandnamemust be strings. A list or an object in any of them fails with a server error.Send
external_idfrom the first sign-in. Readers are matched byemailorexternal_id, andexternal_idis only stored when the reader is created.
Try it on your computer
Before readers depend on your help center, you can run a program on your own computer and walk through the whole flow. Each program starts with one command, once the shared secret is in its environment:
export HELPCENTER_SSO_SECRET='the Shared secret from your settings'
node login-url.js # Node.js
php -S 127.0.0.1:3000 login-url.php # PHP
python3 login_url.py # Python
rackup -p 3000 # Ruby, with the config.ru from the program's comments
In Settings → Single sign-on, set Login URL to
http://localhost:3000/auth/helpcenterand click Save JWT settings. Every visitor is now sent to your computer, so do this only while nobody else uses the help center.Set
HELP_CENTER_URLin the program to your help center's address, and start the program.Open a page of your help center in a private browser window. A HelpCenter.io session in your normal window would change the result.
You should land on that page, signed in. On the way, the browser passes through your Login URL with
subdomainandkey, then through/sso/jwt?jwt=…on your help center.
When you're done, set Login URL back to your real endpoint.
Widget and iframe tokens
The same token function signs tokens for the widget and for an iframe. Pass a new random value as jti instead of a key: crypto.randomUUID() in Node.js, bin2hex(random_bytes(16)) in PHP, secrets.token_hex(16) in Python, or SecureRandom.uuid in Ruby (after require 'securerandom'). See Sign readers into the widget with a JWT and How JWT single sign-on works.
Troubleshooting
"Invalid SSO token." HelpCenter.io rejected the token. Check the secret, the algorithm, the required claims, and that iat and exp are in seconds. The token checker in Troubleshoot single sign-on tells you which rule a token breaks.
After your login, the reader lands on the HelpCenter.io sign-in page. The token's jti isn't the key from this request, or that key was already used.
Your endpoint answers "Missing key". The Login URL contains a #, or your login page dropped the query string when it sent the reader back.
The browser says the page redirected too many times. Usually the browser is signed in to a HelpCenter.io account that isn't part of this help center: use a private window. The other causes, and their fixes, are in Troubleshoot single sign-on.