Skip to content
Swingback, home
Docs / Signing in your users

Signing in your users

Your users already have an account on your product. Your server vouches for them with a signed identity token, and they post, vote and comment as themselves, without a second sign-in.

An identity token works in two places:

  • The widget: your page hands the token to the frame. See Widget.
  • The portal, with single sign-on: the portal sends people to your product to sign in, and your product sends them back with a token.

Identity secrets#

Your server signs tokens with an identity secret. Owners and admins manage them under Settings → Sign-in:

  1. Choose Create a secret. The secret, si_…, shows once: put it in your secrets manager.
  2. Note the secret's id, idsec_…, which the page lists. Tokens name it in their kid header.
  3. Note your workspace's id, ws_…, which the same page shows. Tokens name it in their iss claim.

A workspace has at most 3 live secrets. To rotate one, create a new secret, move your servers to it, then revoke the old one. Tokens the old secret signed stop working at once. The page shows when each secret was last used.

The token#

An identity token is a JWT signed with HS256. The key is the whole secret, si_ included, as text. Its header is { "alg": "HS256", "typ": "JWT", "kid": "idsec_…" }: alg must be HS256, and typ may be left out.

ClaimRequiredValue
issYesYour workspace's id, ws_….
audYesswingback, or an array that contains it.
subYesYour user's id in your product, up to 255 characters. It becomes the customer's external id.
iatYesWhen you signed the token, in seconds since the Unix epoch.
expYesWhen it expires: at most an hour after iat.
nbfNoWhen it starts to work.
jtiFor SSOA unique id, up to 128 characters. Single sign-on needs it and takes each token once.

Clocks may differ by 60 seconds. A token can be up to 16 KB long.

The profile claims are optional:

  • email, name (up to 100 characters) and avatar_url (an http or https address).
  • traits: facts about the user, such as { "plan": "pro", "seats": 12 }. A key starts with a letter and has up to 64 letters, digits or underscores. A value is text (up to 256 characters), a number or true/false; null removes the trait.
  • companies: up to 20 companies the user belongs to. Each has an id, your id for it, and optionally a name, a domain, a value and traits. The value is a whole number, such as its monthly revenue in cents.

For example:

{
  "iss": "ws_01h455vb4pex5vsknk084sn02q",
  "aud": "swingback",
  "sub": "user_8412",
  "iat": 1790640000,
  "exp": 1790640600,
  "jti": "3f1c9e2a-5b7d-4c8e-9a1f-2d6b8e4c7a90",
  "email": "dana@example.com",
  "name": "Dana Reyes",
  "traits": { "plan": "pro", "seats": 12 },
  "companies": [{ "id": "org_311", "name": "Initech", "value": 49900 }]
}

What a token does#

Swingback finds your customer by sub, else by email, else creates one. Someone who signed in to your portal with that email before becomes the same customer. Then the token brings the customer up to date:

  • email, name and avatar_url replace what the customer had. A claim you leave out changes nothing.
  • traits set the keys they name, and leave the others. A key's first value sets its type. Tokens send dates as text, such as 2026-09-29, so define a date trait under Settings → Traits first. A trait that can't be kept (a value of another type, a key past the workspace's 50) is dropped rather than refusing the sign-in.
  • companies is the whole list: the customer leaves the companies you don't name. Leave the claim out to keep them as they are. See Customers and companies.

A token whose email belongs to another customer, or whose profile isn't valid, is refused.

Sign a token in Node.js#

No library needed:

// identity-token.mjs: signs an identity token for your signed-in user. Node.js 18 or later.
import { createHmac, randomUUID } from "node:crypto";

const secret = process.env.SWINGBACK_IDENTITY_SECRET; // si_…, shown once under Settings → Sign-in
const secretId = process.env.SWINGBACK_IDENTITY_SECRET_ID; // idsec_…, the secret's id
const workspaceId = process.env.SWINGBACK_WORKSPACE_ID; // ws_…, your workspace's id

const encode = (object) => Buffer.from(JSON.stringify(object)).toString("base64url");

export function identityToken(user) {
  const now = Math.floor(Date.now() / 1000);
  const header = encode({ alg: "HS256", typ: "JWT", kid: secretId });
  const claims = encode({
    iss: workspaceId,
    aud: "swingback",
    sub: String(user.id),
    iat: now,
    exp: now + 10 * 60, // 10 minutes: at most an hour
    jti: randomUUID(), // single sign-on takes each token once
    email: user.email,
    name: user.name,
    traits: { plan: user.plan, seats: user.seats },
    companies: [{ id: String(user.company.id), name: user.company.name, value: user.company.revenueCents }],
  });
  const signature = createHmac("sha256", secret).update(`${header}.${claims}`).digest("base64url");
  return `${header}.${claims}.${signature}`;
}

Sign a token only for the user signed in to your product, from an endpoint of yours that checks their session. Keep tokens short-lived, and out of addresses, except single sign-on's, which work once.

In the widget#

When the frame says it's ready, your page posts the token to it:

frame.contentWindow.postMessage({ source: "swingback", type: "identify", token }, "https://acme.example.com");

The frame answers identified, or error with a code such as invalid_identity_token. A token works until it expires, so the same one can sign in again after a reload. Widget has a whole page as an example.

Single sign-on for the portal#

With single sign-on, the portal's sign-in page offers Continue with your workspace's name, which takes people to your product. Under Settings → Sign-in, enter your Sign-in page: an https address on your product, such as https://app.example.com/feedback-sign-in. The email code, and Google or GitHub where offered, stay on the portal's sign-in page. Leave the field empty to turn single sign-on off.

  1. The portal sends the visitor to your sign-in page with a return_to parameter: your portal's /auth/sso address, with parameters of its own (a state, sometimes a next page).
  2. Your page signs the visitor in to your product, if they aren't already.
  3. Your page sends them to return_to as it is, with a token parameter added: a token that has a jti.
  4. The portal signs them in and takes them back to where they were. What they wrote as a guest in that browser becomes theirs.
// Once the visitor is signed in to your product, redirect the browser to the address this returns.
import { identityToken } from "./identity-token.mjs";

const portal = "https://acme.example.com";

export function ssoRedirect(returnTo, user) {
  const url = new URL(returnTo);
  // Only to your portal: a link someone else made must not carry your user's token elsewhere.
  if (url.origin !== portal || url.pathname !== "/auth/sso") throw new Error("return_to isn't your portal");
  url.searchParams.set("token", identityToken(user));
  return url.toString();
}

The portal takes each token once: a jti it has seen is refused, and so is any other bad token. A refused token brings the visitor back to the sign-in page with an error.

A link of your product can also point straight to your portal's /auth/sso?token=…, with a fresh token. The portal then asks the visitor to confirm before it signs them in.