Docs
Single sign-on for the widget and portal
People who are already signed in to your app can vote, post and comment on your UpVoiced board without an email code. Your server signs a short-lived token for the signed-in user; the widget or your portal exchanges it for an UpVoiced session.
1. Create the secret
- In the dashboard, open Settings → Single sign-on and choose Create secret. Only owners and admins see this page, and you may be asked to sign in again first.
- Copy the secret (it starts with
uvsso_). It is shown once. Store it on your server asUPVOICED_SSO_SECRET, for example in your secrets manager. Never put it in browser code, mobile apps, logs or git. - Copy the Audience shown on the same page (your workspace id) into
UPVOICED_WORKSPACE_ID. - Turn on Accept SSO tokens from my app.
2. The token
A token is a compact JWT signed with HS256 (HMAC-SHA256), keyed with the secret's UTF-8 bytes. UpVoiced accepts HS256 only: none, RS256, ES256 and other algorithms are rejected.
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload
{
"aud": "<your workspace id>",
"sub": "user-123",
"email": "ada@example.com",
"name": "Ada Lovelace",
"avatar_url": "https://cdn.example.com/avatars/123.png",
"iat": 1792578000,
"exp": 1792578300,
"jti": "9f1c4e1e-2b8a-4c39-9d55-0d6a1f3c7b21"
}
// Token = base64url(header) + "." + base64url(payload) + "." +
// base64url(HMAC-SHA256(secret, base64url(header) + "." + base64url(payload)))| Claim | Required | Rules |
|---|---|---|
aud | Yes | Your workspace id (the Audience). A token for another workspace is rejected. |
sub | Yes, unless email | Your stable user id, as a string of up to 255 characters. |
email | Yes, unless sub | The user's email. Send it only if your app has verified it: UpVoiced uses it for notifications and to link an existing account. |
name | No | Display name, up to 100 characters. |
avatar_url | No | An https:// URL, up to 2,048 characters. |
iat | Yes | Issued-at, in Unix seconds; not in the future. |
exp | Yes | Expiry, in Unix seconds: after iat and at most 10 minutes later. Use 5 minutes. |
jti | Yes | A unique id per token (8–128 printable ASCII characters, e.g. a UUID). Each token works once. |
nbf | No | Not-before, in Unix seconds. |
UpVoiced tolerates 60 seconds of clock difference. Keep your server's clock synchronized (NTP). Other claims are ignored.
3. Sign tokens on your server
Each snippet has no dependencies beyond the language's standard library and returns a token that is valid for 5 minutes and usable once. Sign a new token every time you need one; any JWT library that produces HS256 tokens with these claims works too.
Node.js (sign-token.mjs)
// UpVoiced SSO: sign a token for the user who is signed in to YOUR app. Server-side only.
// Node.js 18+, no dependencies. Keep UPVOICED_SSO_SECRET out of browsers, logs and git.
import { createHmac, randomUUID } from 'node:crypto';
const base64url = (value) => Buffer.from(value).toString('base64url');
/**
* @param {object} options
* @param {string} options.secret UPVOICED_SSO_SECRET (Settings → Single sign-on)
* @param {string} options.workspaceId the Audience shown next to the secret
* @param {{ id: string | number, email?: string, name?: string, avatarUrl?: string }} options.user
* @returns {string} a token valid for 5 minutes, usable once
*/
export function signUpVoicedToken({ secret, workspaceId, user }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
aud: workspaceId,
sub: String(user.id),
email: user.email,
name: user.name,
avatar_url: user.avatarUrl,
iat: now,
exp: now + 300,
jti: randomUUID(),
};
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
const signature = createHmac('sha256', secret).update(signingInput).digest('base64url');
return `${signingInput}.${signature}`;
}
Python (sign_token.py)
# UpVoiced SSO: sign a token for the user who is signed in to YOUR app. Server-side only.
# Python 3.8+, standard library only. Keep UPVOICED_SSO_SECRET out of browsers, logs and git.
import base64
import hashlib
import hmac
import json
import time
import uuid
def _base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def sign_upvoiced_token(secret: str, workspace_id: str, user: dict) -> str:
"""Return a token valid for 5 minutes, usable once."""
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"aud": workspace_id,
"sub": str(user["id"]),
"email": user.get("email"),
"name": user.get("name"),
"avatar_url": user.get("avatar_url"),
"iat": now,
"exp": now + 300,
"jti": str(uuid.uuid4()),
}
payload = {key: value for key, value in payload.items() if value is not None}
signing_input = (
_base64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
+ "."
+ _base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
)
signature = hmac.new(
secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256
).digest()
return signing_input + "." + _base64url(signature)
Ruby (sign_token.rb)
# UpVoiced SSO: sign a token for the user who is signed in to YOUR app. Server-side only.
# Ruby 2.7+, standard library only. Keep UPVOICED_SSO_SECRET out of browsers, logs and git.
require "json"
require "openssl"
require "securerandom"
def upvoiced_base64url(data)
[data].pack("m0").tr("+/", "-_").delete("=")
end
# Returns a token valid for 5 minutes, usable once.
def sign_upvoiced_token(secret:, workspace_id:, user:)
now = Time.now.to_i
header = { alg: "HS256", typ: "JWT" }
payload = {
aud: workspace_id,
sub: user.fetch(:id).to_s,
email: user[:email],
name: user[:name],
avatar_url: user[:avatar_url],
iat: now,
exp: now + 300,
jti: SecureRandom.uuid
}.compact
signing_input = "#{upvoiced_base64url(header.to_json)}.#{upvoiced_base64url(payload.to_json)}"
signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
"#{signing_input}.#{upvoiced_base64url(signature)}"
end
PHP (sign_token.php)
<?php
// UpVoiced SSO: sign a token for the user who is signed in to YOUR app. Server-side only.
// PHP 7.4+, no extensions beyond the defaults. Keep UPVOICED_SSO_SECRET out of browsers, logs and git.
function upvoiced_base64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/** Returns a token valid for 5 minutes, usable once. */
function sign_upvoiced_token(string $secret, string $workspaceId, array $user): string
{
$now = time();
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$payload = array_filter([
'aud' => $workspaceId,
'sub' => (string) $user['id'],
'email' => $user['email'] ?? null,
'name' => $user['name'] ?? null,
'avatar_url' => $user['avatar_url'] ?? null,
'iat' => $now,
'exp' => $now + 300,
'jti' => bin2hex(random_bytes(16)),
], function ($value) {
return $value !== null;
});
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR;
$signingInput = upvoiced_base64url(json_encode($header, $flags)) . '.'
. upvoiced_base64url(json_encode($payload, $flags));
$signature = hash_hmac('sha256', $signingInput, $secret, true);
return $signingInput . '.' . upvoiced_base64url($signature);
}
Go (sign_token.go)
// UpVoiced SSO: sign a token for the user who is signed in to YOUR app. Server-side only.
// Go 1.18+, standard library only. Keep UPVOICED_SSO_SECRET out of browsers, logs and git.
package upvoiced
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"time"
)
// User is the signed-in user of your app. Only ID is required (or Email).
type User struct {
ID string
Email string
Name string
AvatarURL string
}
// SignToken returns a token valid for 5 minutes, usable once.
func SignToken(secret, workspaceID string, user User) (string, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return "", err
}
now := time.Now().Unix()
claims := map[string]any{
"aud": workspaceID,
"iat": now,
"exp": now + 300,
"jti": hex.EncodeToString(jti),
}
optional := map[string]string{
"sub": user.ID, "email": user.Email, "name": user.Name, "avatar_url": user.AvatarURL,
}
for key, value := range optional {
if value != "" {
claims[key] = value
}
}
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encode := base64.RawURLEncoding.EncodeToString
signingInput := encode(header) + "." + encode(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
return signingInput + "." + encode(mac.Sum(nil)), nil
}
4. Widget: UpVoiced.identify
Expose an endpoint that returns a fresh token to the signed-in user, then call UpVoiced.identify on every page where the widget loads. The widget keeps the UpVoiced session in memory only (never cookies or local storage), for up to 12 hours.
import express from 'express';
import { signUpVoicedToken } from './sign-token.mjs';
const app = express();
// requireLogin is your own middleware: only signed-in users get a token.
app.get('/upvoiced/token', requireLogin, (req, res) => {
const token = signUpVoicedToken({
secret: process.env.UPVOICED_SSO_SECRET,
workspaceId: process.env.UPVOICED_WORKSPACE_ID,
user: { id: req.user.id, email: req.user.email, name: req.user.name },
});
res.set('Cache-Control', 'no-store').json({ token });
});<script>
window.UpVoiced = window.UpVoiced || { q: [] };
['identify', 'config', 'open', 'close', 'on', 'track'].forEach(function (m) {
window.UpVoiced[m] =
window.UpVoiced[m] ||
function () {
window.UpVoiced.q.push([m].concat([].slice.call(arguments)));
};
});
// Ask your server for a fresh token for the signed-in user, then identify.
fetch('/upvoiced/token', { credentials: 'same-origin' })
.then(function (response) {
return response.json();
})
.then(function (body) {
UpVoiced.identify({ token: body.token });
});
UpVoiced.on('identify', function (result) {
if (!result.ok) console.warn('UpVoiced sign-in failed:', result.error);
});
</script>
<script async src="https://cdn.upvoiced.com/w.js" data-workspace="your-slug"></script>identify returns a promise and emits identify with { ok: true, user } or { ok: false, error }; it never throws and never exposes the token.
// When the user signs out of your app:
UpVoiced.identify(null);5. Portal: redirect with a token
To open your portal already signed in, redirect the browser to https://your-slug.upvoiced.com/_auth/sso?token=…&return=/path (or your custom domain). The portal exchanges the token, sets its own secure session cookie (12 hours) and continues to return, a path on the portal. If the visitor started a vote on the portal before signing in, it is completed automatically.
// A "Feedback" link in your app points here; the user lands signed in on your portal.
app.get('/feedback', requireLogin, (req, res) => {
const token = signUpVoicedToken({
secret: process.env.UPVOICED_SSO_SECRET,
workspaceId: process.env.UPVOICED_WORKSPACE_ID,
user: { id: req.user.id, email: req.user.email, name: req.user.name },
});
const url = new URL('https://your-slug.upvoiced.com/_auth/sso');
url.searchParams.set('token', token);
url.searchParams.set('return', '/'); // a path on your portal, e.g. /b/feature-requests
res.redirect(303, url.href);
});6. Existing accounts
- A token whose
subUpVoiced has seen before signs in that same person. If the email changed in your app, UpVoiced updates it. - Otherwise, if someone in your workspace already signed in with an email code using the same
email, the token signs in that account and links it to yoursub: their votes, posts and follows stay with them. That is why you must only send emails your app has verified. - An email that already belongs to a different
subis refused (sso_identity_conflict) instead of merging two people. - People who deleted their data, or are being deleted, cannot sign in.
7. Rotate the secret
In Settings → Single sign-on, choose Rotate secret and how long the old secret keeps working: 24 hours by default, up to 7 days. Deploy the new secret to your servers within that window. If the secret leaked, choose Stop it now: tokens signed with the old secret are rejected at once. Only one previous secret is kept, so finish one rotation before starting the next.
8. Errors
POST /v1/public/auth/sso answers { "error": { "code", "message", "details" } }. The token is never echoed back or logged.
| Status and code | Meaning |
|---|---|
401 sso_token_invalid | details[0].code says why: malformed, algorithm(not HS256), signature (wrong or old secret, or edited token), audience, expired, issued_in_future, not_yet_valid, lifetime (exp more than 10 minutes after iat or now), claims (see details[0].path), identity(no sub or email) or replayed (the token was already used). |
403 sign_in_method_disabled | Single sign-on is off, or no secret exists yet. |
409 sso_identity_conflict | The sub and email belong to two different people. |
409 deletion_pending, 409 end_user_unavailable | The person is being deleted or was deleted. |
429 rate_limited | Too many attempts or rejected tokens from one IP address, or too many sign-ins for one person; see Retry-After. |