Identify users

Attribute widget activity to signed-in users with a server-signed ssoToken: JWT claims, server examples, anonymous visitors, custom attributes, portal sign-in, and secret rotation.

JM
James Morton
Written By James MortonLast updated 1 day ago

The widget loads for every visitor after init. Identify a signed-in user so their votes, posts, and conversations attach to their account instead of an anonymous session. This article covers signing tokens on your server, the JWT claims, anonymous visitors, custom attributes, portal sign-in, and rotating the secret.

How identification works

Identification is verified-only. Your server signs a short-lived JWT with your workspace's signing secret, and the browser sends that token to the widget as ssoToken. Quackback rejects unsigned { id, email, name } sent from the browser.

The signing secret lives in your app's server environment, under any name you like. It is not a Quackback setting or a Quackback environment variable, and it is available on every plan. Do not set QUACKBACK_WIDGET_SECRET on your Quackback server.

const { ssoToken } = await fetch("/widget-token").then((r) => r.json());
Quackback.identify({ ssoToken });
// Script tag: Quackback("identify", { ssoToken })

Note: Anonymous visitors can use the widget right after init. Their session is created on their first action that needs one (for example an upload or a reaction).

Identify signed-in users

Call identify after login, on page load if the user is already signed in, and whenever the signed-in user changes. Once per session is enough, not on every navigation.

  1. Get the signing secret. The agent install prompt stores it for you. To copy it by hand, go to Settings > Widget > Install, find Signing secret, and use Reveal and Copy. Put it in a server-only environment variable in your app.
  2. Sign a JWT on your server (HS256, expiring in about 5 minutes) with sub and email.
  3. Send { ssoToken } to the page the same way you already expose session data.
  4. Call Quackback.identify({ ssoToken }).

Warning: Never put the signing secret in client-side code, commits, or logs. Sign tokens on your server only.

JWT claims

Claim

Required

Notes

sub

Yes

Stable unique ID from your system. id is accepted as an alias. Do not use the email as sub.

email

Yes

Used for notifications and to match existing accounts

name

No

Display name

avatarUrl or avatarURL

No

Avatar image URL

segments

No

Array of segment slugs. Unknown slugs are skipped. Dropping a slug removes that widget-added membership.

exp

Recommended

Unix timestamp, about 5 minutes from now

custom

No

Extra keys become custom attributes if you configured them

sub is the durable key across devices. If a user changes their email in your app, the same sub still resolves to the same Quackback account, and the account adopts the new email.

These claims are reserved and never stored as attributes: sub, id, email, name, avatarURL, avatarUrl, segments, iat, exp, nbf, iss, aud, jti.

Teammates in the widget

Identify signs the visitor in as a customer in the widget only. It never unlocks the admin dashboard, even when the email belongs to a workspace admin or member. Teammates can identify and use the widget the way a customer would, and their dashboard name and avatar are not overwritten by your app's token.

Sign a token on your server

The secret's environment variable name is yours to choose. The examples use WIDGET_SIGNING_SECRET.

Next.js

// app/api/widget-token/route.ts
import { SignJWT } from "jose";
import { getServerSession } from "next-auth";

const secret = new TextEncoder().encode(process.env.WIDGET_SIGNING_SECRET);

export async function GET() {
  const session = await getServerSession();
  if (!session?.user) {
    return new Response("Unauthorized", { status: 401 });
  }

  const token = await new SignJWT({
    email: session.user.email,
    name: session.user.name,
  })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(session.user.id)
    .setExpirationTime("5m")
    .sign(secret);

  return Response.json({ ssoToken: token });
}

Express

// routes/widget-token.js
const jwt = require("jsonwebtoken");

router.get("/widget-token", requireAuth, (req, res) => {
  const token = jwt.sign(
    {
      sub: req.user.id,
      email: req.user.email,
      name: req.user.name,
    },
    process.env.WIDGET_SIGNING_SECRET,
    { algorithm: "HS256", expiresIn: "5m" }
  );

  res.json({ ssoToken: token });
});

Django

# views.py
import jwt
import time
from django.conf import settings
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required

@login_required
def widget_token(request):
    payload = {
        "sub": str(request.user.id),
        "email": request.user.email,
        "name": request.user.get_full_name(),
        "exp": int(time.time()) + 300,  # 5 minutes
    }
    token = jwt.encode(payload, settings.WIDGET_SIGNING_SECRET, algorithm="HS256")
    return JsonResponse({"ssoToken": token})

Rails

# app/controllers/widget_tokens_controller.rb
require "jwt"

class WidgetTokensController < ApplicationController
  before_action :authenticate_user!

  def show
    payload = {
      sub: current_user.id.to_s,
      email: current_user.email,
      name: current_user.name,
      exp: 5.minutes.from_now.to_i,
    }
    token = JWT.encode(payload, ENV["WIDGET_SIGNING_SECRET"], "HS256")
    render json: { ssoToken: token }
  end
end

Laravel

// config/services.php
// 'widget' => ['signing_secret' => env('WIDGET_SIGNING_SECRET')],

// app/Http/Controllers/WidgetTokenController.php
use Firebase\JWT\JWT;

class WidgetTokenController extends Controller
{
    public function show(Request $request)
    {
        $payload = [
            'sub'   => (string) $request->user()->id,
            'email' => $request->user()->email,
            'name'  => $request->user()->name,
            'exp'   => time() + 300, // 5 minutes
        ];

        $token = JWT::encode($payload, config('services.widget.signing_secret'), 'HS256');

        return response()->json(['ssoToken' => $token]);
    }
}

Protect the Laravel route with your auth middleware so only signed-in users get a token.

Tip: Mint a fresh token on each login or page load. A short expiry limits the damage if a token is intercepted.

Identify at init

If you already have a token when the widget loads, pass it to init and skip the separate identify call:

Quackback.init({
  instanceUrl: "https://feedback.example.com",
  identity: { ssoToken },
});

Omit identity for anonymous visitors, or if you will call identify once your sign-in check finishes.

Anonymous visitors

Do nothing. After init, the launcher is visible and the visitor is anonymous:

Quackback.init({ instanceUrl: "https://feedback.example.com" });

There is no anonymous flag to pass. If the visitor later signs in and you call identify, their anonymous activity merges into their account.

Switch users and log out

Call identify again with a new token when the signed-in user changes. You do not need logout in between.

Quackback.identify({ ssoToken: userBToken });

On logout, clear the identity. The widget stays visible for anonymous use, and the panel closes if it was open.

Quackback.logout();

Custom attributes

Add claims such as plan, company, or mrr to the signed JWT. Matching attributes show on the user's profile and can be used in segments.

// Server-side payload
const payload = {
  sub: user.id,
  email: user.email,
  name: user.name,
  plan: "pro",
  company: "Acme Corp",
  mrr: 299,
};

Define the attributes first in Settings > Users. Unknown keys are ignored, not rejected.

Sign portal users in from the widget

If you run a private portal, an identified widget user can open the portal already signed in, with no password and no second sign-up.

  1. Identify widget users with ssoToken (this article).
  2. Turn on Widget sign-in in Settings > Access & Security > Portal access.

They stay a customer on the portal. Workspace teammates can identify in the widget, but going to the portal from the widget does not sign them in: they see the portal sign-in screen (or keep an existing dashboard login). See Control portal access and sign-in.

Rotate the secret

If the signing secret is exposed, regenerate it in Settings > Widget > Install (Regenerate next to the signing secret), then deploy the new value in your app. The old secret stops working immediately.

Warning: Regenerate and deploy together. Signed-in users are not recognized until your server signs tokens with the new secret. The launcher and anonymous visitors are unaffected.

Was this helpful?

Your feedback shapes what we write next.