Single sign-on

Build your Login URL

Export
Download Markdown Use with AI

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_SECRET in 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

subdomain

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.

key

A one-time value. Put it in the token's jti claim. It works for one successful sign-in.

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

  1. Read key from the query string. If it's missing, answer 400 Bad Request.

  2. 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.

  3. Sign a token with HS256 and the shared secret, with the claims jti (the key), iat (now, in seconds), exp (iat plus 300), email, name and external_id (your stable user ID).

  4. Redirect with 302 Found to https://<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

  • jti is the key. A token with any other jti, 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. iat and exp are 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 subdomain parameter: a crafted link could make your endpoint send a valid token to another site. If one Login URL serves several help centers, look subdomain up 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/jwt doesn'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. aud must be one string, and email and name must be strings. A list or an object in any of them fails with a server error.

  • Send external_id from the first sign-in. Readers are matched by email or external_id, and external_id is 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
  1. In Settings → Single sign-on, set Login URL to http://localhost:3000/auth/helpcenter and click Save JWT settings. Every visitor is now sent to your computer, so do this only while nobody else uses the help center.

  2. Set HELP_CENTER_URL in the program to your help center's address, and start the program.

  3. Open a page of your help center in a private browser window. A HelpCenter.io session in your normal window would change the result.

  4. You should land on that page, signed in. On the way, the browser passes through your Login URL with subdomain and key, 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.

Was this article helpful?