Skip to content
On this page

Guides

Sign your users in

Your backend signs a short-lived token for the signed-in user. Shipbell checks it with your public key and never holds anything that could sign in as your users.

How it works

Shipbell has no accounts for your users. Your app vouches for them: your backend signs a JSON Web Token (JWT) with a private key that only you hold, and Shipbell verifies it with the matching public key. The token is used in two places:

  • In your app. The widget or ShipbellKit trades the token for a Shipbell session.
  • On your board. "Continue with" your app sends the visitor to your app, which signs them in and returns a token to the board.

Shipbell never accepts your identity provider's own token, such as a Supabase access token, because that token works as a key to your own API. There is no unsigned "identify" mode.

Signing keysComing soon

  • The admin creates an ECDSA P-256 key pair in your browser with WebCrypto. It shows or downloads the private key once, in PKCS#8 format, and sends only the public key, as a JWK, to Shipbell.
  • You can also upload a public JWK of your own.
  • Each key has a key id, kid, that goes into the token header.
  • A project can have two active keys at a time, so you can rotate: add a new key, then mark the old one as retiring. A retiring key is revoked automatically after 24 hours.

Keep the private key in your backend's environment only, never in an app, a web page or a repository.

Token format

The header names the algorithm, your key id and the token type:

JSON
{ "alg": "ES256", "kid": "<kid>", "typ": "sb-sso+jwt" }

The token carries these claims:

ClaimRequiredValue
issYesYour project's public id, prj_…
audYeshttps://api.shipbell.app for the in-app exchange. Your board's canonical origin, such as https://<slug>.shipbell.app, for board sign-in
subYesYour stable id for the user, at most 255 characters
iat, expYesexp minus iat is at most 600 seconds. Issue tokens for 300 seconds
jtiYesA unique id with at least 128 random bits. Each token works only once
nonceBoard sign-in onlyThe state value the board sent to your app
email, email_verifiedNoUsed for notifications
given_name, family_nameNoOnly names the user actually set. Never a display name that falls back to the email address
localeNoThe user's locale
traitsNoAn object with the keys your project allows, such as plan, at most 2 KB. Other keys are dropped

There is no avatar claim.

Sign a token

This Node.js example uses the jose library to sign a token for the in-app exchange. Your route checks your own session first and signs a token only for the user who is signed in. Leave out the optional claims you have no value for.

JavaScript
import { randomBytes } from 'node:crypto';
import { importPKCS8, SignJWT } from 'jose';

const privateKey = await importPKCS8(process.env.FEEDBACK_SSO_PRIVATE_KEY, 'ES256');

export function feedbackToken(user) {
  return new SignJWT({ email: user.email, email_verified: user.emailVerified })
    .setProtectedHeader({ alg: 'ES256', kid: process.env.FEEDBACK_SSO_KID, typ: 'sb-sso+jwt' })
    .setIssuer(process.env.FEEDBACK_PROJECT_ID)
    .setAudience('https://api.shipbell.app')
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime('5m')
    .setJti(randomBytes(16).toString('base64url'))
    .sign(privateKey);
}

What Shipbell checksComing soon

  • The project comes from the X-Project-Key header in the in-app exchange, or from the board's address for board sign-in, never from the token.
  • The signing key is looked up in that project by kid. An unknown or revoked key is rejected.
  • iss must be the project's public id, and aud must match.
  • Only ES256 is accepted. A token with alg set to none or to an HS algorithm is rejected.
  • The typ header must be sb-sso+jwt.
  • sub, jti, iat and exp must be present. A token older than 10 minutes, or whose exp is more than 600 seconds after its iat, is rejected. Clocks may differ by up to 60 seconds.
  • Each jti works once, so a token cannot be replayed.

Trade a token for a sessionComing soon

The widget and ShipbellKit do this for you. To call the API yourself, send the token with your publishable key:

curl
curl -X POST https://api.shipbell.app/v1/sessions/sso \
  -H 'X-Project-Key: sb_pk_live_…' \
  -H 'Idempotency-Key: <a new UUID for each request>' \
  -H 'Content-Type: application/json' \
  -d '{"token":"<the token from your backend>"}'
JavaScript
const response = await fetch('https://api.shipbell.app/v1/sessions/sso', {
  method: 'POST',
  headers: {
    'X-Project-Key': 'sb_pk_live_…',
    'Idempotency-Key': '<a new UUID for each request>',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    "token": "<the token from your backend>"
  })
});
const data = await response.json();
Swift
var request = URLRequest(url: URL(string: "https://api.shipbell.app/v1/sessions/sso")!)
request.httpMethod = "POST"
request.setValue("sb_pk_live_…", forHTTPHeaderField: "X-Project-Key")
request.setValue("<a new UUID for each request>", forHTTPHeaderField: "Idempotency-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(#"""
{
  "token": "<the token from your backend>"
}
"""#.utf8)
let (data, response) = try await URLSession.shared.data(for: request)

The answer is {session_token, expires_at, user}. The session token starts with sb_ses_ and lasts 24 hours at most. Keep it in memory only. When a call answers 401, get a new token from your backend and trade it again.

Banning or erasing a user ends all of their sessions.

Sign in on your boardComing soon

Voting, posting, commenting and following on your board open a sign-in sheet, and the action runs once the visitor is signed in. "Continue with" your app works like this:

  1. The board makes a random state and keeps it, with the page to return to, in a short-lived cookie on the board.
  2. The board sends the visitor to the sign-in URL set for your project, with ?state=<state>. It never sends a return address, so neither side has an open redirect.
  3. Your app checks its own session and signs the visitor in if needed. It then signs a token with aud set to the board's origin and nonce set to the state.
  4. Your app redirects to https://<slug>.shipbell.app/sso/callback#token=<jwt>&state=<state>. The token travels in the fragment, so it stays out of logs and the Referer header.
  5. The callback page removes the fragment and sends the token and the state to the board.
  6. The board checks the token, the state and that the token was never used before. It then signs the visitor in and returns them to the page they were on.

Open the board from your appComing soon

The widget and ShipbellKit open your board already signed in, with a one-time code that works once and for 60 seconds. The board shows "Continue as" and the name the user will appear under, and uses the code only when they confirm, so a link preview can never use it up.

How users appearComing soon

On a board, a user appears as your project's name followed by "user" until they choose otherwise. Before their first public post or comment they can switch to their first name and last initial, which needs given_name in the token, or to a name of their own. Email addresses are never shown, and there are no avatars.