# JWT claims and validation rules

_Category: Single sign-on_

Every token that signs a reader in to your help center follows one contract, whether it comes back from your Login URL to `/sso/jwt`, arrives in an iframe's `?jwt=`, or rides in the widget's `Authorization` header. This page lists every claim and every check HelpCenter.io applies.

A token HelpCenter.io accepts, decoded. The header:

```
{
  "alg": "HS256",
  "typ": "JWT"
}
```

The payload, for sign-in through your Login URL:

```
{
  "jti": "kT9wQ48213pZ2mV",
  "iat": 1767225600,
  "exp": 1767225900,
  "email": "jane.doe@example.com",
  "name": "Jane Doe",
  "external_id": "4815"
}
```

## Signing

- **Algorithm:** HS256 (HMAC with SHA-256) only. A token signed with HS384, HS512 or RS256, or with `alg` set to `none`, is rejected even when its signature is correct.
- **Key:** the **Shared secret** from your settings, exactly as shown, used as a string. It has at least 64 characters, and a generated one looks like base64, but it isn't decoded: a token signed with the decoded bytes is rejected.
- **Header:** `alg` is required. `typ` is optional, and `kid` is ignored.

## Claims

| Claim | Type | Rule | What HelpCenter.io does with it |
| --- | --- | --- | --- |
| `jti` | string | Required. For sign-in through your Login URL, the `key` HelpCenter.io sent. For the iframe and the widget, a new value for every token. | Makes each token single use (see below). |
| `iat` | number, Unix seconds | Required. At most 30 seconds in the future. For the widget, also at most **Token TTL** plus 30 seconds in the past. | Checked only. |
| `exp` | number, Unix seconds | Required. The token works until 30 seconds after `exp`. There's no upper limit, so keep it short: `iat` plus 300. | Checked only. |
| `nbf` | number, Unix seconds | Optional. At most 30 seconds in the future. | Checked only. |
| `email` | string | Required, not empty. Its format isn't checked. Up to 240 characters. | Identifies the reader, together with `external_id`. Matched without regard to letter case. Saved when the reader is created. |
| `name` | string | Required, not empty. | Split at the first space into a first and last name, and shown on the reader's comments. Saved when the reader is created. |
| `external_id` | string | Optional, recommended: your own stable user ID. A number also works. Up to 140 characters. | Identifies the reader, together with `email`. Saved only when the reader is created. |
| `iss` | string | Required when your settings have an **Issuer**, and then equal to it, letter case included. Ignored otherwise. | Checked only. |
| `aud` | string | Required when your settings have an **Audience**, and then equal to it. Always one string, never a list. | Checked only. |
| `lang` | string | Optional. Up to 10 characters. | Saved when the reader is created (otherwise your help center's default language is saved). The help center doesn't use it. |
| `avatar_url` | string | Optional. Up to 255 characters. | Saved when the reader is created. The help center doesn't show it. |
| `custom_fields` | object | Optional. | Saved when the reader is created. The help center doesn't use it. |
| `role` | any | Ignored. | Every SSO visitor is a reader, whatever the claim says. |
| `sub`, `groups`, `roles`, others | any | Ignored. `sub` doesn't replace `email`. | Nothing. |

Numbers sent as strings, such as `"iat": "1767225600"`, are accepted, and so are fractions of a second.

## Time checks

All times are Unix timestamps in seconds. HelpCenter.io allows 30 seconds of difference between your clock and its own:

| Check | Accepted | Rejected |
| --- | --- | --- |
| `iat` ahead of HelpCenter.io's clock | Up to 30 seconds | More than 30 seconds |
| `nbf` ahead of HelpCenter.io's clock | Up to 30 seconds | More than 30 seconds |
| `exp` behind HelpCenter.io's clock | Less than 30 seconds | 30 seconds or more |
| Widget only: seconds since `iat` | Up to **Token TTL** plus 30 | More than **Token TTL** plus 30 |

For example, a token whose `exp` passed 20 seconds ago still signs a reader in, and one whose `exp` passed 40 seconds ago doesn't. With **Token TTL** at 300, the widget accepts a token issued 320 seconds ago and rejects one issued 340 seconds ago.

Milliseconds are the most common mistake: `Date.now()` in JavaScript returns milliseconds, which puts `iat` thousands of years in the future, and the token is rejected. Use `Math.floor(Date.now() / 1000)`.

## Single use

- **Sign-in through your Login URL:** `jti` must be the `key` HelpCenter.io sent to your Login URL. Each key works for one successful sign-in, on the help center that issued it. A token HelpCenter.io rejects doesn't use up the key.
- **Iframe and widget:** `jti` can be any value you haven't used before on this help center, such as a UUID. Use each `jti` once. When HelpCenter.io sees a `jti` it already accepted, an iframe page with a reused token loads as if it had none, and on a private help center the widget answers `403 Forbidden`. The iframe and the widget share this record, so a token used in an iframe can't be reused in the widget.

## How long tokens and sessions last

| Flow | The token is accepted while | After sign-in |
| --- | --- | --- |
| Login URL (`/sso/jwt`) | `exp` hasn't passed by 30 seconds or more | A session that lasts until 24 hours pass without a visit |
| Iframe (`?jwt=`) | `exp` hasn't passed by 30 seconds or more | The same session, kept only when your page and the help center are on the same site |
| Widget (`Authorization: Bearer`) | `exp` hasn't passed by 30 seconds or more, and the token is no older than **Token TTL** plus 30 seconds | Nothing: each request carries its own token |

**Token TTL** is set in your settings, from 30 to 86,400 seconds, and 300 is recommended. It applies to widget tokens only. For how long a token should live, see [How long should my SSO tokens live?](https://self.helpcenter.io/content/how-long-should-my-tokens-live)

## What the reader record keeps

The first valid token for a person creates their reader record. After that:

- **Details are written once.** `name`, `email`, `external_id`, `lang`, `avatar_url` and `custom_fields` keep the values from the first sign-in. For example, a reader who first signed in as "Mary Jane Watson" (first name Mary, last name Jane Watson) keeps that name when a later token says "Mary Watson-Parker", and a new `avatar_url`, `lang` or `custom_fields` in that token is ignored.
- **A known `external_id` wins over a new email.** A token with a new `email` and a known `external_id` signs in the existing reader, and the saved email stays the first one.
- **`external_id` is saved only at creation.** A reader created without one stays matched by email only. If their email changes later, the next token creates a second reader, even with an `external_id`. Send `external_id` from the first sign-in. See [What if a reader's email changes on our side?](https://self.helpcenter.io/content/what-if-a-user-s-email-changes-on-my-side)
- **Letter case doesn't matter.** `Jane.Doe@Example.com` and `jane.doe@example.com` sign in the same reader.

## Tokens that cause a server error

A few tokens fail with a server error (HTTP 500) instead of being rejected, in all three flows. Readers see HelpCenter.io's "Whoops, something crashed." page, and widget requests fail:

- `aud` as a list, such as `["helpcenter-prod", "other"]`, even when no **Audience** is set. Some libraries produce a list when you give them several audiences.
- `email`, `name` or another string claim sent as an object or a list.
- On a reader's first sign-in: `external_id`, `lang` or `avatar_url` over the limits in the table above.

An `email` over its limit doesn't cause an error: through your Login URL the reader lands on the HelpCenter.io sign-in page, and in an iframe or the widget the token is refused like an invalid one. The token checker in [Troubleshoot single sign-on](https://developers.helpcenter.io/content/troubleshoot-sso) catches all of these.

## What HelpCenter.io answers

| Outcome | Login URL (`/sso/jwt`) | Iframe (`?jwt=`) | Widget |
| --- | --- | --- | --- |
| Token accepted | `302 Found` to the page first asked for, signed in | The page, signed in | The data, as that reader |
| Token rejected (signature, algorithm, missing claim, time, Issuer or Audience) | `401 Unauthorized` with the text "Invalid SSO token." | The token is ignored and the visitor stays signed out | `403 Forbidden`, code `SITE_AUTH_REQUIRED`, on a private help center |
| `jti` isn't an unused key of this help center | `302 Found` to the HelpCenter.io sign-in page | Not checked | Not checked |
| No token | `400 Bad Request` with the text "Missing jwt parameter." | A private help center sends the visitor to your Login URL | `403 Forbidden`, code `SITE_AUTH_REQUIRED`, on a private help center |

HelpCenter.io doesn't say why it rejected a token. [Troubleshoot single sign-on](https://developers.helpcenter.io/content/troubleshoot-sso) has a checker that applies the same rules and names the one a token breaks.

## Related

- [Build your Login URL](https://developers.helpcenter.io/content/build-your-login-url)
- [How JWT single sign-on works](https://developers.helpcenter.io/content/how-jwt-sso-works)
- [Sign readers into the widget with a JWT](https://developers.helpcenter.io/content/widget-jwt)
