Identify & SSO
By default the widget captures anonymous feedback. To attach a verified user
identity — so submissions are tied to a real account and votes are deduped per
person — call identify with an auth token you sign on your server.
Never ship your signing secret to the browser. The browser only ever sees the already-signed token.
The flow
- Your backend signs a short-lived token over a canonical identity message.
- You hand
{ userId, email, auth }to the browser. - You call
luuphub('identify', { userId, email, auth }). - Luuphub verifies the signature and binds the identity to the session.
Why a canonical message (not userId + email)
Concatenating identity fields is ambiguous — ("alice", "@b.com") and
("alice@b.com", "") both concatenate to the same string, so one signed pair's
signature would verify for a different pair (identity confusion → takeover).
Every signer below produces a byte-identical canonical JSON message with a
FIXED field order:
{"v":1,"aud":"luuphub-identify","sub":<userId>,"email":<email>,"iat":<unix>,"exp":<unix>}
auddomain-separates the signature to the identify use-case.sub/emailare always present as strings (empty when absent).hmac= base64url(HMAC-SHA256(secret, canonical)); the token is also emitted as an HS256 JWT over its ownheader.payload.- Keep it short-lived.
exp - iatmust be at most 300 seconds — the server rejects a longer window. A captured token is only usable for minutes.
Reference signers (five languages)
Each snippet is the exact, copy-paste signer from our repo. They all emit
byte-identical canonical output for ASCII input. Provide claimsJson as
{"sub":"u_123","email":"a@b.com","iat":<unix>,"exp":<unix>}; each prints
{ canonical, hmac, jwt }.
Node.js
Run it: node sign.node.mjs <secret> '<claimsJson>'
// Luuphub identify signing — Node.js reference (R16/R69).
//
// Run in YOUR backend to sign an identify payload with your per-site secret, then
// hand { userId, email, auth } to the browser for `luuphub('identify', …)`.
//
// node sign.node.mjs <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. The canonical HMAC message has a FIXED field
// order + iat/exp (never a userId+email concat); the JWT is HS256.
import { createHmac } from 'node:crypto';
const AUD = 'luuphub-identify';
const V = 1;
const secret = process.argv[2];
const claims = JSON.parse(process.argv[3]);
const sub = String(claims.sub ?? '');
const email = String(claims.email ?? '');
const iat = Number(claims.iat);
const exp = Number(claims.exp);
const b64url = (buf) => Buffer.from(buf).toString('base64url');
const jstr = (s) => JSON.stringify(s); // JSON string escaping (matches all snippets for ASCII)
// FIXED-ORDER canonical message — the exact bytes the server re-derives + checks.
const canonical =
`{"v":${V},"aud":${jstr(AUD)},"sub":${jstr(sub)},"email":${jstr(email)},"iat":${iat},"exp":${exp}}`;
const hmac = createHmac('sha256', secret).update(canonical, 'utf8').digest('base64url');
// HS256 JWT (self-contained: the server re-signs the token's own header.payload).
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = b64url(JSON.stringify({ aud: AUD, sub, email, iat, exp }));
const signingInput = `${header}.${payload}`;
const jwtSig = createHmac('sha256', secret).update(signingInput, 'utf8').digest('base64url');
const jwt = `${signingInput}.${jwtSig}`;
process.stdout.write(JSON.stringify({ canonical, hmac, jwt }));
Python
Run it: python3 sign.py <secret> '<claimsJson>'
# Luuphub identify signing — Python reference (R16/R69).
#
# Run in YOUR backend to sign an identify payload with your per-site secret.
#
# python3 sign.py <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
#
# Prints { canonical, hmac, jwt }. The canonical HMAC message has a FIXED field
# order + iat/exp (never a userId+email concat); the JWT is HS256.
import sys
import json
import hmac
import hashlib
import base64
AUD = "luuphub-identify"
V = 1
secret = sys.argv[1]
claims = json.loads(sys.argv[2])
sub = str(claims.get("sub", ""))
email = str(claims.get("email", ""))
iat = int(claims["iat"])
exp = int(claims["exp"])
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def jstr(s: str) -> str:
# ensure_ascii=False so ASCII output matches the other snippets byte-for-byte.
return json.dumps(s, ensure_ascii=False)
# FIXED-ORDER canonical message — the exact bytes the server re-derives + checks.
canonical = '{"v":%d,"aud":%s,"sub":%s,"email":%s,"iat":%d,"exp":%d}' % (
V,
jstr(AUD),
jstr(sub),
jstr(email),
iat,
exp,
)
hmac_sig = b64url(hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest())
# HS256 JWT (self-contained: the server re-signs the token's own header.payload).
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(
json.dumps(
{"aud": AUD, "sub": sub, "email": email, "iat": iat, "exp": exp},
separators=(",", ":"),
).encode()
)
signing_input = header + "." + payload
jwt_sig = b64url(hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).digest())
jwt = signing_input + "." + jwt_sig
print(json.dumps({"canonical": canonical, "hmac": hmac_sig, "jwt": jwt}))
Ruby
Run it: ruby sign.rb <secret> '<claimsJson>'
# Luuphub identify signing — Ruby reference (R16/R69).
#
# ruby sign.rb <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
#
# Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
# + iat/exp (never a userId+email concat); the JWT is HS256.
require "json"
require "openssl"
require "base64"
AUD = "luuphub-identify"
V = 1
secret = ARGV[0]
claims = JSON.parse(ARGV[1])
sub = (claims["sub"] || "").to_s
email = (claims["email"] || "").to_s
iat = claims["iat"].to_i
exp = claims["exp"].to_i
def b64url(bytes)
Base64.urlsafe_encode64(bytes).delete("=")
end
def jstr(str)
str.to_json
end
canonical = %({"v":#{V},"aud":#{jstr(AUD)},"sub":#{jstr(sub)},"email":#{jstr(email)},"iat":#{iat},"exp":#{exp}})
hmac_sig = b64url(OpenSSL::HMAC.digest("SHA256", secret, canonical))
header = b64url({ alg: "HS256", typ: "JWT" }.to_json)
payload = b64url({ aud: AUD, sub: sub, email: email, iat: iat, exp: exp }.to_json)
signing_input = "#{header}.#{payload}"
jwt_sig = b64url(OpenSSL::HMAC.digest("SHA256", secret, signing_input))
jwt = "#{signing_input}.#{jwt_sig}"
print({ canonical: canonical, hmac: hmac_sig, jwt: jwt }.to_json)
PHP
Run it: php sign.php <secret> '<claimsJson>'
<?php
// Luuphub identify signing — PHP reference (R16/R69).
//
// php sign.php <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
// + iat/exp (never a userId+email concat); the JWT is HS256.
$AUD = 'luuphub-identify';
$V = 1;
$secret = $argv[1];
$claims = json_decode($argv[2], true);
$sub = (string) ($claims['sub'] ?? '');
$email = (string) ($claims['email'] ?? '');
$iat = (int) $claims['iat'];
$exp = (int) $claims['exp'];
function b64url(string $bytes): string {
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// Match the other snippets: no slash/unicode escaping (JS never escapes '/').
function jstr(string $s): string {
return json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$canonical = '{"v":' . $V . ',"aud":' . jstr($AUD) . ',"sub":' . jstr($sub)
. ',"email":' . jstr($email) . ',"iat":' . $iat . ',"exp":' . $exp . '}';
$hmac = b64url(hash_hmac('sha256', $canonical, $secret, true));
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT'], $flags));
$payload = b64url(json_encode(
['aud' => $AUD, 'sub' => $sub, 'email' => $email, 'iat' => $iat, 'exp' => $exp],
$flags
));
$signingInput = "$header.$payload";
$jwtSig = b64url(hash_hmac('sha256', $signingInput, $secret, true));
$jwt = "$signingInput.$jwtSig";
echo json_encode(['canonical' => $canonical, 'hmac' => $hmac, 'jwt' => $jwt], $flags);
Go
Run it: go run sign.go <secret> '<claimsJson>'
// Luuphub identify signing — Go reference (R16/R69).
//
// go run sign.go <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
// + iat/exp (never a userId+email concat); the JWT is HS256.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
)
const aud = "luuphub-identify"
const version = 1
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func jstr(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func mac(secret, msg string) []byte {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(msg))
return h.Sum(nil)
}
func main() {
secret := os.Args[1]
var claims map[string]interface{}
if err := json.Unmarshal([]byte(os.Args[2]), &claims); err != nil {
panic(err)
}
sub, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
iat := int64(claims["iat"].(float64))
exp := int64(claims["exp"].(float64))
canonical := fmt.Sprintf(`{"v":%d,"aud":%s,"sub":%s,"email":%s,"iat":%d,"exp":%d}`,
version, jstr(aud), jstr(sub), jstr(email), iat, exp)
hmacSig := b64url(mac(secret, canonical))
header := b64url([]byte(`{"alg":"HS256","typ":"JWT"}`))
payloadBytes, _ := json.Marshal(map[string]interface{}{
"aud": aud, "sub": sub, "email": email, "iat": iat, "exp": exp,
})
payload := b64url(payloadBytes)
signingInput := header + "." + payload
jwt := signingInput + "." + b64url(mac(secret, signingInput))
out, _ := json.Marshal(map[string]string{"canonical": canonical, "hmac": hmacSig, "jwt": jwt})
fmt.Print(string(out))
}
On the client
// 'auth' is the 'jwt' (or 'hmac') your backend just produced.
luuphub('identify', { userId: 'u_123', email: 'a@b.com', auth });
The server re-derives the canonical message, checks the signature with your
per-site secret, and enforces the expiry window before trusting the identity. A
forged, wrong-secret, alg:none, or expired token is rejected.