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 (PHP, Node.js, Python 3, C#/.NET 8 — MIT licensed).
Prefer a ready-made client?The PHP SDK (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
- Your server builds a small JSON object describing the currently logged-in user.
- It encodes and signs it into a single string,
ssoData(format below). - Your page injects that string into the GraphComment integration snippet.
- 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_keyare 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 formatssoData 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 ofmessage + " " + timestamp(wheremessageis the base64 part), keyed with yoursso_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 |
idmust be stable and immutableIt 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
ssoData on your serverNode.js
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: '[email protected]', // required
language: 'fr', // optional
picture: '', // optional — full URL only
};
console.log(gcSsoData(user, GC_SSO_PRIVATE_KEY));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' => '[email protected]', // required
'language' => 'fr', // optional
'picture' => '', // optional — full URL only
);
echo gc_sso_data($user, GC_SSO_PRIVATE_KEY) . PHP_EOL;Python 3
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': '[email protected]', # required
'language': 'fr', # optional
'picture': '', # optional — full URL only
}
print(gc_sso_data(user, GC_SSO_PRIVATE_KEY))
C# tooA .NET 8 version lives in the repo: dotnet/example.cs. All four examples are runnable as-is and print the
ssoDatafor 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:
<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:
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 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:
<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:
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
id scheme laterThinking 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_keyand the signedsso_datastring belong in the page. - Never put
ssoDatain a page URL (address bar, links, redirects): URLs leak through browser history andRefererheaders. 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
ssoDatacould 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 rejectedssoDatadoesn't always mean a bad signatureIf 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/500above), 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:
- 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. - Is the signature hex-encoded? The server expects the hexadecimal HMAC digest. A base64-encoded digest never matches.
- Is the key the
sso_private_key? Not your shortname, not the SSO public key. - Exactly three parts, single spaces?
base64 SP signature SP timestamp. No extra whitespace, no newlines (beware of shell tools that append\n). - 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. - Compare against a known-good implementation. Run one of the repo examples with your own private key and the same user object, and diff the two
ssoDatastrings 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
ssoDatathat is expired on arrival. - Cached pages. If your HTML is cached (CDN, full-page cache), the embedded
ssoDataages with the cache and dies after 5 minutes. Exclude thessoDatafrom 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 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
onConflict firesGraphComment 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 — every other
__semio__paramsoption. - Mobile (WebView) — SSO inside a native app's WebView (
gcSsoLogin).
Updated about 1 month ago
