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:
{ "alg": "ES256", "kid": "<kid>", "typ": "sb-sso+jwt" }The token carries these claims:
| Claim | Required | Value |
|---|---|---|
iss | Yes | Your project's public id, prj_… |
aud | Yes | https://api.shipbell.app for the in-app exchange. Your board's canonical origin, such as https://<slug>.shipbell.app, for board sign-in |
sub | Yes | Your stable id for the user, at most 255 characters |
iat, exp | Yes | exp minus iat is at most 600 seconds. Issue tokens for 300 seconds |
jti | Yes | A unique id with at least 128 random bits. Each token works only once |
nonce | Board sign-in only | The state value the board sent to your app |
email, email_verified | No | Used for notifications |
given_name, family_name | No | Only names the user actually set. Never a display name that falls back to the email address |
locale | No | The user's locale |
traits | No | An 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.
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-Keyheader 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. issmust be the project's public id, andaudmust match.- Only ES256 is accepted. A token with
algset tononeor to an HS algorithm is rejected. - The
typheader must besb-sso+jwt. sub,jti,iatandexpmust be present. A token older than 10 minutes, or whoseexpis more than 600 seconds after itsiat, is rejected. Clocks may differ by up to 60 seconds.- Each
jtiworks 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 -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>"}'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();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:
- The board makes a random
stateand keeps it, with the page to return to, in a short-lived cookie on the board. - 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. - Your app checks its own session and signs the visitor in if needed. It then signs a token with
audset to the board's origin andnonceset to the state. - 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 theRefererheader. - The callback page removes the fragment and sends the token and the state to the board.
- 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.