# How JWT single sign-on works

_Category: Single sign-on_

JWT single sign-on lets people who are signed in to your product read your private help center without a second login. Your server vouches for each reader with a short-lived token, signed with a secret that only you and HelpCenter.io know, and HelpCenter.io signs the reader in.

This page explains the three ways a token reaches HelpCenter.io, what a valid token does, and what single sign-on doesn't do. To write the endpoint, see [Build your Login URL](https://developers.helpcenter.io/content/build-your-login-url). For every claim and check, see [JWT claims and validation rules](https://developers.helpcenter.io/content/jwt-claims).

**Plans:** Setting up single sign-on needs the Catalyst plan. A setup saved earlier keeps working, and stays editable, on other plans. **Who sets it up:** Owners and Admins, in the dashboard. Your developers build the Login URL.

## The pieces

| Piece | What it is |
| --- | --- |
| Login URL | An endpoint in your app, for example `https://app.example.com/auth/helpcenter`. HelpCenter.io sends readers there to sign in. You enter its address in your help center's settings. |
| Shared secret | A string of at least 64 characters, generated in your settings or chosen by you. Your server signs tokens with it using HS256, and HelpCenter.io checks every signature against it. It stays on your server. |
| Token | A JSON Web Token (JWT) that names the reader. It needs `jti`, `iat`, `exp`, `email` and `name`, and should carry `external_id`. |
| `/sso/jwt` | The address on your help center where your Login URL sends the reader back with the token: `https://help.example.com/sso/jwt?jwt=<token>`. |

## Three ways a token signs a reader in

All three use the same shared secret and the same claims. They differ in how the token travels, what `jti` holds, and what happens after sign-in.

| Where | How the token arrives | `jti` | After sign-in |
| --- | --- | --- | --- |
| Your help center | HelpCenter.io sends the reader to your Login URL, which sends them back to `/sso/jwt?jwt=<token>`. | The `key` HelpCenter.io sent to your Login URL | A session on the help center |
| Your help center in an iframe | Your page loads a help center address with `?jwt=<token>` added. | A new value for every token | A session on the help center, kept only when your page and the help center are on the same site |
| The widget | Your page passes a token in `window.hcOptions.jwt`, and each widget request carries one in `Authorization: Bearer <token>`. | A new value for every token | No session: every request carries its own token |

## Sign-in on your help center

This is what happens when someone opens your help center directly. It applies when the help center is set to **Private**.

1. A visitor opens a page of your help center, for example `https://help.example.com/content/billing?ref=app`.
2. They aren't signed in, so HelpCenter.io remembers the page, query string included, and creates a one-time key. It sends the visitor to your Login URL with two query parameters: `subdomain`, your help center's subdomain (the part before `.helpcenter.io`, even when you use a custom domain), and `key`.
3. Your endpoint finds the user in your own session. If nobody is signed in, it shows your login page first, then comes back to the same URL with the same `key`.
4. It signs a token with HS256 and the shared secret, with `jti` set to the `key`, and redirects to `https://help.example.com/sso/jwt?jwt=<token>`.
5. HelpCenter.io checks the signature and the claims, and checks that the `key` is an unused key of this help center. It finds or creates the reader, signs them in, and marks the key as used.
6. The reader lands on the page from step 1.

The first redirect, as the visitor's browser sees it:

```
GET /content/billing?ref=app HTTP/1.1
Host: help.example.com

HTTP/1.1 302 Found
Location: https://app.example.com/auth/helpcenter?subdomain=acme&key=kT9wQ48213pZ2mV
```

And the last one, once your endpoint has sent the reader back with a valid token. The response also sets the session cookie.

```
GET /sso/jwt?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJrVDl3UTQ4MjEzcFoybVYi... HTTP/1.1
Host: help.example.com

HTTP/1.1 302 Found
Location: https://help.example.com/content/billing?ref=app
```

These are browser redirects: HelpCenter.io never calls your Login URL from its servers. Your readers' browsers follow every step, so the endpoint only has to be reachable by them.

## Sign-in in an iframe

When you show your help center inside your app, you can sign the reader in with the first page you load.

1. Your server signs a token for the user, with a new random `jti`.
2. Your page loads any help center address with the token added: `<iframe src="https://help.example.com/content/billing?jwt=<token>">`.
3. HelpCenter.io checks the token, signs the reader in with a session, and the page renders.

Three things to know about this flow:

- **The session lasts beyond the first page only on the same site.** Browsers keep the help center's session cookie in the frame when your page and the help center share a site, for example `help.example.com` (your custom domain) inside `app.example.com`. With `acme.helpcenter.io` inside `app.example.com`, the first page is signed in and the next page sends the frame to your Login URL. If your Login URL signs readers straight back in, the frame then goes around in a loop until the browser gives up.
- **A token that doesn't work is ignored.** An invalid, expired or already used token, or any token for a visitor who is already signed in, is skipped without an error. The visitor carries on without it, and a private help center sends them to your Login URL.
- **Only `exp` limits the token.** The **Token TTL** setting doesn't apply here. Keep `exp` short, because the token stays in the frame's address.

The embed itself (the script, the frame's height, allowed origins) is in [Embed your help center in your app](https://developers.helpcenter.io/content/embed-your-help-center).

## Sign-in in the widget

1. Your page gets a token for the signed-in user from your server, with a new random `jti`, and passes it in `window.hcOptions.jwt`, together with an `onAuthExpired` function that returns a fresh token.
2. The widget hands the first token to its frame in the URL fragment (`#jwt=`), which browsers don't send to servers.
3. Every widget request carries a token in `Authorization: Bearer <token>`. After the first request, the widget asks `onAuthExpired` for a new one.
4. HelpCenter.io checks each token, including its age against **Token TTL**, and answers as that reader. No cookie is involved.
5. Without a valid token, the widget's requests to a private help center answer `403 Forbidden` with the code `SITE_AUTH_REQUIRED`.

The complete setup, with code, is in [Sign readers into the widget with a JWT](https://developers.helpcenter.io/content/widget-jwt).

## What a valid token does

- **Finds or creates the reader.** HelpCenter.io looks the reader up by `email` or `external_id` and creates one on the first sign-in. The name, email and profile claims are saved then, and later tokens don't change them. See [JWT claims and validation rules](https://developers.helpcenter.io/content/jwt-claims).
- **Lets them read.** A reader can open a private help center and read everything that's open to all readers. Categories for team members only, and categories or articles shared with selected team members, stay hidden.
- **Keeps them signed in.** On the help center and in an iframe, the reader gets a session that lasts until 24 hours pass without a visit. Each visit starts the 24 hours again. The widget keeps no session.
- **Never opens the dashboard.** A `role` claim is ignored, and every reader has the same access. Your team keeps signing in with their HelpCenter.io accounts.

## What single sign-on doesn't do

- **No roles, groups or per-reader access.** Every SSO reader sees the same content. Claims such as `role`, `roles` or `groups` are ignored, and private categories are shared with team members, not with readers. Audiences that need different content need different help centers. See [Can different readers get different access?](https://self.helpcenter.io/content/can-different-users-get-different-roles)
- **No sign-out.** The help center has no sign-out link, and the **Logout URL** setting is saved but not used. A reader stays signed in until 24 hours pass without a visit, even after you sign them out of your app or remove them from it.
- **No reader list.** Readers don't appear under **Users**, and you can't remove or suspend them.
- **No switching off in the dashboard.** Once you save the settings, switching off **Enable Single Sign-On** only hides them, and the saved setup keeps working. To stop sending readers to your Login URL, change the help center's visibility, or [contact support](https://self.helpcenter.io/content/contact-support) to remove the setup.
- **One setup per help center.** One Login URL, one shared secret, and one optional Issuer and Audience. See [Can I have more than one SSO configuration on a help center?](https://self.helpcenter.io/content/can-i-have-multiple-sso-configurations-on-one-help-center)
- **HS256 only.** RS256 tokens, public keys, JWKS and OpenID Connect ID tokens aren't accepted. SAML 2.0 and OpenID Connect show as **Coming soon** in the dashboard. Reader sign-in is also unrelated to the tokens of [OAuth 2.1 for apps and AI clients](https://developers.helpcenter.io/content/oauth).
- **The Login URL is for private help centers.** A **Public** help center never sends visitors to it, although a token in an iframe or the widget still signs them in. A **Password** help center shows its password page instead. Visitors outside your IP allow-list land on the HelpCenter.io sign-in page.

## Set it up

The settings are in your dashboard under **Settings** → **Single sign-on**. The steps are in [Sign readers in with your own login (JWT SSO)](https://self.helpcenter.io/content/jwt-sso).

![Screen recording. Under Single sign-on, switch on Enable Single Sign-On. Enter your Login URL and generate a Shared secret. Click Save JWT settings.](https://helpcenter-io.s3.amazonaws.com/uploads/self/6b0h1nqnSOAGl2qxr65hTaQz7TnB205to5qqVXGu.gif)
Generate creates a 64-character secret for you.

What each setting means for your code:

| Setting | What it means for your code |
| --- | --- |
| **Login URL** | Required. The address HelpCenter.io sends readers to. HelpCenter.io adds `?subdomain=…&key=…` to it, so it must not contain a `?` or `#` of its own. |
| **Shared secret** | Required, at least 64 characters. Your server signs with this exact string. A generated secret looks like base64, but don't decode it. |
| **Issuer (iss claim)** | Optional. When set, every token needs an `iss` claim with exactly this value. |
| **Audience (aud claim)** | Optional. When set, every token needs an `aud` claim with exactly this value, as one string. |
| **Token TTL (seconds)** | Widget tokens only: the oldest token the widget accepts, counted from `iat`, with 30 seconds of tolerance. From 30 to 86,400; 300 is recommended. |
| **Logout URL** | Saved but not used. |

## Security notes

- **Keep the secret on your server.** Anyone who has it can sign in as any reader, because the token's `email` and `external_id` are the reader's identity. Never put it in browser code.
- **Write your help center's address into your code.** Never build the redirect from the `subdomain` parameter, or a crafted link could send a valid token to another site.
- **Keep `exp` short.** 300 seconds leaves plenty of time. Tokens for your help center and for iframes travel in URLs, which can end up in browser history and server logs. The widget passes its first token in the URL fragment, which browsers don't send to servers.
- **Use a different secret for each help center.** A token is accepted by every help center that uses the secret it was signed with, unless their **Audience** settings tell them apart.
- **Rotate with care.** A help center has one secret at a time. A new secret takes effect as soon as you save it: tokens signed with the old one fail from then on, and readers who are already signed in stay signed in. See [Can I rotate the SSO shared secret without downtime?](https://self.helpcenter.io/content/can-i-rotate-the-shared-secret-without-downtime)
- **Keep your clock in sync.** HelpCenter.io allows 30 seconds of difference on `iat`, `nbf` and `exp`. Use NTP on the servers that sign tokens.

## Related

- [Build your Login URL](https://developers.helpcenter.io/content/build-your-login-url)
- [JWT claims and validation rules](https://developers.helpcenter.io/content/jwt-claims)
- [Troubleshoot single sign-on](https://developers.helpcenter.io/content/troubleshoot-sso)
- [Sign readers into the widget with a JWT](https://developers.helpcenter.io/content/widget-jwt)
