---
updatedAt: 2026-07-31T14:16:47.000Z
agentTools:
  projectIndex: https://developers.graphcomment.com/llms.txt
---

# Single Sign-On

Sign your users in to GraphComment with their existing account — server snippets in Node.js, PHP and Python, plus a real troubleshooting guide.

GraphComment's **unidirectional SSO** lets your logged-in users comment with the account they already have on your site. No separate registration, no OAuth dance: your server signs a small JSON object describing the user, your page hands it to the widget, and GraphComment signs the user in.

Working example code for every snippet on this page lives in the public repo **[graphcomment/sso](https://github.com/graphcomment/sso)** (PHP, Node.js, Python 3, C#/.NET 8 — MIT licensed).

> 👍 Prefer a ready-made client?
> The **PHP SDK** ([graphcomment/sdk-api-php](https://github.com/graphcomment/sdk-api-php), v3, MIT) wraps the server-to-server calls documented in the API Reference — user management, comment export, thread data — with the request signing handled for you.

## How it works

1. **Your server** builds a small JSON object describing the currently logged-in user.
2. It encodes and signs it into a single string, `ssoData` (format below).
3. Your page injects that string into the GraphComment integration snippet.
4. The widget exchanges it for a session: the user is signed in to GraphComment.

The signing key never leaves your server. The page only ever sees the signed result.

## Before you start

Enable **unidirectional SSO** in your back-office: **Settings → Authentication → Unidirectional SSO**. That's also where your SSO keys are displayed.

You'll work with three distinct values — don't mix them up:

| Value             | What it is                                                                                                             | Where to find it                               |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `graphcomment_id` | Your **shortname** — the unique identifier of your site, the same value you already use in your standard embed snippet | Back-office, with your embed snippet           |
| `sso_public_key`  | The **SSO public key** — identifies your site on SSO calls; safe to expose in the page                                 | Settings → Authentication → Unidirectional SSO |
| `sso_private_key` | The **SSO secret key** — signs the HMAC. **Server-side only, never in a page**                                         | Same place                                     |

> ⚠️ The SSO keys are a dedicated pair
> `sso_public_key` / `sso_private_key` are distinct from your shortname. Using your shortname (or any other value) as the signing key is the single most common cause of "invalid sso data" errors.

## The `ssoData` format

`ssoData` is one string of **3 parts separated by single spaces**:

```
ssoData = base64(JSON(user)) + " " + signature + " " + timestamp
```

* `timestamp` — current unix time, in **seconds**, generated at request time.
* `signature` — **hexadecimal** HMAC-SHA1 of `message + " " + timestamp` (where `message` is the base64 part), keyed with your `sso_private_key`.

A `ssoData` expires **about 5 minutes** after its timestamp. Sign a fresh one on every page render — never cache it, never hardcode the timestamp.

### User fields

The JSON object accepts these fields:

| Field      | Required | Notes                                                                                                           |
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `id`       | ✅        | Unique, **stable and immutable** — see the warning below                                                        |
| `username` | ✅ \*     | Display name. \* Ignored if "manage pseudos" is enabled on your site (GraphComment then assigns pseudos itself) |
| `email`    | ✅        | The user's e-mail address                                                                                       |
| `language` | —        | ISO 639-1 code (`en`, `fr`, …). Default: `en`                                                                   |
| `picture`  | —        | Avatar — **full URL only**. Ignored if "manage avatars" is enabled on your site                                 |

> ❗ `id` must be stable and immutable
> It is the identity anchor on GraphComment's side: if it changes, the user loses their comment history; if it is reused, someone else inherits it. Use a primary key — never an e-mail address or a display name, both of which can change.

## Generate `ssoData` on your server

### Node.js

```javascript
const crypto = require('node:crypto');

const GC_SSO_PRIVATE_KEY = '<replace-with-your-sso-private-key>';

function gcSsoData(user, privateKey) {
  const message = Buffer.from(JSON.stringify(user)).toString('base64');
  const timestamp = Math.floor(Date.now() / 1000); // generate at REQUEST time — expires after ~5 minutes
  const signature = crypto
    .createHmac('sha1', privateKey)
    .update(message + ' ' + timestamp)
    .digest('hex');
  return message + ' ' + signature + ' ' + timestamp;
}

const user = {
  id: 'gc-test-001',            // required, unique, immutable
  username: 'Émilie Dupré 日本', // required (unless "manage pseudos" is enabled)
  email: 'emilie@example.test', // required
  language: 'fr',               // optional
  picture: '',                  // optional — full URL only
};

console.log(gcSsoData(user, GC_SSO_PRIVATE_KEY));
```

### PHP

```php
<?php

define('GC_SSO_PRIVATE_KEY', '<replace-with-your-sso-private-key>');

function gc_sso_data(array $user, $privateKey) {
    // JSON_UNESCAPED_* keeps the payload as raw UTF-8, like the other language examples.
    // JSON_THROW_ON_ERROR: fail loudly if the user data is not valid UTF-8 (e.g. a
    // latin1 database) — otherwise json_encode() returns false and the ssoData is
    // silently empty while its signature stays valid.
    $message   = base64_encode(json_encode(
        $user,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
    ));
    $timestamp = time(); // generate at REQUEST time — ssoData expires after ~5 minutes
    $signature = hash_hmac('sha1', $message . ' ' . $timestamp, $privateKey); // hex
    return $message . ' ' . $signature . ' ' . $timestamp;
}

$user = array(
    'id'       => 'gc-test-001',          // required, unique, immutable
    'username' => 'Émilie Dupré 日本',     // required (unless "manage pseudos" is enabled)
    'email'    => 'emilie@example.test',  // required
    'language' => 'fr',                   // optional
    'picture'  => '',                     // optional — full URL only
);

echo gc_sso_data($user, GC_SSO_PRIVATE_KEY) . PHP_EOL;
```

### Python 3

```python
import base64
import hashlib
import hmac
import json
import time

GC_SSO_PRIVATE_KEY = '<replace-with-your-sso-private-key>'


def gc_sso_data(user, private_key):
    # .decode('ascii') is REQUIRED: b64encode returns bytes, and interpolating
    # bytes into the string would produce a broken "b'...'" ssoData.
    message = base64.b64encode(
        json.dumps(user, ensure_ascii=False).encode('utf-8')
    ).decode('ascii')
    timestamp = int(time.time())  # generate at REQUEST time — expires after ~5 minutes
    signature = hmac.new(
        private_key.encode('utf-8'),
        f'{message} {timestamp}'.encode('utf-8'),
        hashlib.sha1,
    ).hexdigest()
    return f'{message} {signature} {timestamp}'


user = {
    'id': 'gc-test-001',             # required, unique, immutable
    'username': 'Émilie Dupré 日本',   # required (unless "manage pseudos" is enabled)
    'email': 'emilie@example.test',  # required
    'language': 'fr',                # optional
    'picture': '',                   # optional — full URL only
}

print(gc_sso_data(user, GC_SSO_PRIVATE_KEY))
```

> 👍 C# too
> A .NET 8 version lives in the repo: [dotnet/example.cs](https://github.com/graphcomment/sso/blob/master/dotnet/example.cs). All four examples are runnable as-is and print the `ssoData` for a demo user — handy for comparing your own implementation's output.

## Put it in the page

Inject the generated string into the SSO integration snippet:

```html
<div id="graphcomment"></div>
<script type="text/javascript">
  window.gc_params = {
    graphcomment_id: '<your shortname>',
    sso_public_key:  '<your SSO public key>',
    // dynamically replaced with the ssoData generated on YOUR server:
    sso_data:        '<the ssoData string>',
  };

  (function() {
    var gc = document.createElement('script'); gc.type = 'text/javascript'; gc.async = true;
    gc.src = 'https://graphcomment.com/js/integration.js';
    (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(gc);
  })();
</script>
```

## Logging a user out

When your user logs out of your site, render the page with an **empty** `sso_data`:

```javascript
window.gc_params = {
  graphcomment_id: '<your shortname>',
  sso_public_key:  '<your SSO public key>',
  sso_data:        '',   // empty string = log the user out of GraphComment
};
```

An empty value explicitly triggers the widget's logout: the embed script tells the widget to sign the user out, and the widget clears its stored session. Any other falsy value (`null`, `false`) is treated the same way as the empty string. Leaving `sso_data` completely undefined does nothing — no login, no logout.

Note that "does nothing" is not "keeps the user signed in": in an embed, the widget does not restore SSO sessions on its own from one page load to the next. A signed-in user needs a fresh `sso_data` on **every** page render (see [The `ssoData` format](#the-ssodata-format) above) — omit it and the widget simply shows up signed out after the next reload.

Do **not** try to sign an empty user payload as a "logout message" — the server rejects it.

## Single-page applications

In a SPA, authentication has to work without a full page reload. Instead of the `window.gc_params` snippet, use the `helpers_sso.js` client library to run the SSO handshake and receive a session JWT, then feed that JWT to the widget.

Two tokens are involved — keep them apart:

|                          | `ssoData`                                 | JWT                                             |
| ------------------------ | ----------------------------------------- | ----------------------------------------------- |
| Generated by             | Your server (SSO private key)             | GraphComment, after the handshake               |
| Used                     | Once, during the handshake                | For the whole session                           |
| Handed to the widget via | `__semio__helpers_sso({ data: ssoData })` | `subscribeToToken(C)` → `C(jwt)`                |
| Never                    | Put it in a URL                           | Replace it with `ssoData` in `subscribeToToken` |

Expose a small endpoint on your backend that returns a fresh `ssoData` for the logged-in user (using any of the server snippets above), then wire the client like this:

```html
<div id="graphcomment"></div>
<script src="https://integration.graphcomment.com/helpers_sso.js"></script>
<script>
  let pushToken  = null;  // GraphComment's internal callback
  let currentJWT = "";

  const __semio__params = {
    graphcommentId: "<your shortname>",
    behaviour: {
      uid: "article-123"
    },
    auth: {
      subscribeToToken: function (C) {
        pushToken = C;
        if (currentJWT) C(currentJWT);   // hand over the JWT once available
      },
      signup: () => openSignupModal(),   // delegate auth UI to your SPA
      login:  () => openLoginModal(),
      logout: () => performLogout()
    }
  };

  // SSO handshake → receives the JWT in onSuccess
  function runSsoHandshake(ssoData) {
    __semio__helpers_sso({
      graphcommentId: "<your shortname>",
      publicKey: "<your SSO public key>",
      data: ssoData,
      onSuccess: function (jwt) {
        currentJWT = jwt;
        if (pushToken) pushToken(jwt);   // push the JWT to the widget
      },
      onFailure:  () => console.warn("SSO handshake failed"),
      onConflict: () => console.warn("Username conflict")
    });
  }

  async function fetchSsoDataAndHandshake() {
    const res = await fetch("/api/graphcomment/sso", { credentials: "include" });
    if (!res.ok) return;
    const { ssoData } = await res.json();
    runSsoHandshake(ssoData);
  }

  // Boot: load gc.js, mount the widget, run the handshake
  (function boot() {
    const s = document.createElement("script");
    s.src = "https://integration.graphcomment.com/gc.js?" + Date.now();
    s.async = true;
    s.defer = true;
    s.onload = () => {
      window.__semio__gc(__semio__params);
      fetchSsoDataAndHandshake();
    };
    (document.head || document.body).appendChild(s);
  })();
</script>
```

The handshake callbacks:

| Callback         | Fires when                                                                                                                                                                                                              |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onSuccess(jwt)` | A handshake succeeded — GraphComment validated your `ssoData` and returns the session JWT. Push it to the widget through `subscribeToToken` each time (it fires again on every handshake you re-run, e.g. for refresh). |
| `onConflict()`   | GraphComment detects an inconsistency between the data you sent and what it has stored (e.g. same e-mail but a different username). Resolve on your side, regenerate a `ssoData`, retry.                                |
| `onFailure()`    | Token validation failed (invalid signature, expired timestamp) — see Troubleshooting below.                                                                                                                             |

**Keeping the session alive.** The JWT lives for about an hour. Periodically fetch a fresh `ssoData` from your backend and re-run the handshake; each `onSuccess` pushes the new JWT to the widget through `subscribeToToken`.

**Switching threads without a reload.** On internal navigation, update `behaviour.uid` and re-mount:

```javascript
function switchThread({ uid }) {
  __semio__params.behaviour = __semio__params.behaviour || {};
  __semio__params.behaviour.uid = uid;

  window.__semio__gc(__semio__params);                 // re-mount on the new thread
  if (currentJWT && pushToken) pushToken(currentJWT);  // re-inject the current JWT
}
```

## Changing your `id` scheme later

Thinking of switching the field you use as `id` (say, from usernames to database IDs)? Comments are **not** linked to your `id` directly — they're linked to GraphComment profiles. On the first SSO login after the change, GraphComment looks the user up by the new `id`, falls back to matching by e-mail, and updates the stored `id` automatically. History is preserved, no duplicate account — **as long as the user's e-mail hasn't changed in the meantime**. If both `id` and e-mail changed for some users, contact us before deploying the switch.

## Security notes

* **The private key never leaves your server.** Only `graphcomment_id`, `sso_public_key` and the signed `sso_data` string belong in the page.
* **Never put `ssoData` in a page URL** (address bar, links, redirects): URLs leak through browser history and `Referer` headers. Note: two legacy internal calls of the widget itself (notifications, user data) still pass it in an XHR URL segment — this is a known legacy behavior of the widget, **do not reproduce it in your own pages**.
* **Generate the timestamp at request time.** A hardcoded or cached timestamp is a security hole: any intercepted `ssoData` could be replayed indefinitely. It also simply stops working after 5 minutes.
* **About HMAC-SHA1.** The signature uses HMAC-SHA1, kept for compatibility across existing integrations. SHA-1's known weaknesses are collision attacks, which do not apply to HMAC with a secret key — this is not a collision-sensitive use.

## Troubleshooting

The handshake failed, the user isn't signed in — here's how to find out why. Open your browser's network tab and look at the SSO call's response: GraphComment's errors are precise.

### What the server tells you

| Response                                                   | Meaning                                                                                              | Fix                                                                                                                                                |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` — `the public key you provided is invalid`           | The `sso_public_key` you sent doesn't match any site.                                                | You're probably sending your shortname (or a placeholder) instead of the **SSO public key**. They are different values — see the table at the top. |
| `403` — `sso data you provided is invalid`                 | The signature doesn't match what the server recomputes.                                              | Wrong signing input or wrong key — run the checklist below.                                                                                        |
| `401` — `route expired`                                    | The timestamp is older than the freshness window (\~5 min).                                          | Clock drift or a cached `ssoData` — see "Signatures are fine but it still expires".                                                                |
| `400` — `Malformed UTF-8 data`                             | The base64 part can't be decoded.                                                                    | Classic Python symptom: interpolating `b'...'` bytes instead of a decoded string. Add `.decode('ascii')` after `b64encode`.                        |
| `500` — `field id is required` / `field email is required` | The signature was **valid**, but the decoded payload is missing a required field.                    | Your user object is incomplete (e.g. an unset variable serialized as `null`). Fix the payload, not the signature.                                  |
| `409`                                                      | Username conflict — surfaces as `onConflict()` in the SPA flow. The response body may be plain text. | See "`onConflict` fires" below.                                                                                                                    |

> 📘 A rejected `ssoData` doesn't always mean a bad signature
> If your code signs a *broken* payload, the signature over that broken payload is still valid — so the server rejects the request for what's *in* the payload (`400`/`500` above), not for the signature. Read the error body before touching your HMAC code.

### Checklist: "sso data you provided is invalid"

Work through these in order — they cover every cause we've seen:

1. **Are you signing `message + " " + timestamp`?** Signing the base64 message alone (without the timestamp) is the most common mistake. The signed string is the message, a single space, then the timestamp.
2. **Is the signature hex-encoded?** The server expects the **hexadecimal** HMAC digest. A base64-encoded digest never matches.
3. **Is the key the `sso_private_key`?** Not your shortname, not the SSO *public* key.
4. **Exactly three parts, single spaces?** `base64 SP signature SP timestamp`. No extra whitespace, no newlines (beware of shell tools that append `\n`).
5. **Is the payload UTF-8 end to end?** Encoding the JSON as ASCII silently mangles accented and non-Latin characters (`Émilie` → `?milie`). The signature stays valid, so this corruption is invisible in HTTP — it shows up as broken usernames. Encode in UTF-8 everywhere.
6. **Compare against a known-good implementation.** Run one of the [repo examples](https://github.com/graphcomment/sso) with your own private key and the same user object, and diff the two `ssoData` strings part by part. The first part that differs tells you which step is wrong.

### Signatures are fine but it still expires

* **Server clock drift.** The timestamp must be within \~5 minutes of GraphComment's clock. Sync your server with NTP; a container or VM with a drifting clock produces `ssoData` that is expired on arrival.
* **Cached pages.** If your HTML is cached (CDN, full-page cache), the embedded `ssoData` ages with the cache and dies after 5 minutes. Exclude the `ssoData` from caching — fetch it from a small uncached endpoint instead (the SPA pattern above works well for this).
* **Seconds, not milliseconds.** The timestamp is unix time in seconds. A millisecond timestamp is thousands of years in the future — and rejected.

### The user is signed in but with a mangled name

That's the UTF-8 corruption from point 5 of the checklist: the payload was encoded as ASCII somewhere. It notably affects .NET's `Encoding.ASCII` — use `Encoding.UTF8` (the [repo's C# example](https://github.com/graphcomment/sso/blob/master/dotnet/example.cs) does).

### The username you send is silently ignored

If **"manage pseudos"** is enabled on your site, GraphComment assigns display names itself and overrides the `username` from your payload — with a `200`, so nothing looks wrong in the network tab. Same logic for avatars with **"manage avatars"** and the `picture` field. Check both settings in your back-office before hunting for a bug in your code.

### `onConflict` fires

GraphComment found an inconsistency between the identity you sent and what it has stored — typically the same e-mail with a different username, or a username already taken by another account. Resolve it on your side (e.g. have the user pick another display name), regenerate the `ssoData` server-side with the corrected data, and re-run the handshake.

## Next steps

* **[Widget configuration](./widget-configuration)** — every other `__semio__params` option.
* **[Mobile (WebView)](./mobile-webview)** — SSO inside a native app's WebView (`gcSsoLogin`).