Guides
Sign identity tokens from your backend
Copy the mint endpoint for Node, PHP, Python or Go, build the identical ES256 claim set, publish your JWKS, and prove it with the conformance checker.
On this page
AI Assistant needs exactly one endpoint on your API to recognize a signed-in customer: one that requires your own session and returns a freshly signed proof. Four ready-to-copy samples build the identical claim set in Node, PHP, Python and Go, so the language you already run is never a reason to hand-roll a JWT.
1. Pick your language, copy one file
Node (zero dependencies): https://busymate.ai/sdk/v1/kit/backend/node/mint.mjs. PHP (zero dependencies): https://busymate.ai/sdk/v1/kit/backend/php/mint.php. Python (cryptography): https://busymate.ai/sdk/v1/kit/backend/python/mint.py. Go (standard library): https://busymate.ai/sdk/v1/kit/backend/go/mint.go. Each builds the same response from the same claims; only the syntax differs.
2. The one shape every language builds
The Node sample shows the reference shape — the others build the identical claims in their own syntax:
import { SignJWT, importJWK } from "jose";
// POST https://YOUR-PRODUCT-DOMAIN/api/bmai/identity — requires YOUR OWN logged-in product session.
app.post("/api/bmai/identity", requireSession, async (req, res) => {
// Fresh on EVERY mint. Never persist the launch token/nonce in localStorage,
// sessionStorage, cookies, React state, or module state: every assistant
// launch consumes this pair exactly once.
const nonce = typeof req.body?.nonce === "string" ? req.body.nonce : "";
if (!/^[A-Za-z0-9_-]{32,200}$/.test(nonce)) return res.status(400).json({ error: "invalid_nonce" });
const key = await importJWK(JSON.parse(process.env.AI_LAUNCH_PRIVATE_JWK), "ES256");
const token = await new SignJWT({
tenant_id: process.env.BMAI_TENANT_ID, // the tenant id shown in Console → Identity
nonce, // equals the sibling field below
name: req.user.displayName, // optional low-sensitivity display claim
})
.setProtectedHeader({ alg: "ES256", kid: process.env.AI_LAUNCH_KEY_ID })
.setIssuer(process.env.BMAI_ISSUER) // the Issuer you registered in Console → Identity
.setAudience("busymate-ai")
.setSubject(req.user.id) // IMMUTABLE internal account id; never email/phone/session id
.setJti(crypto.randomUUID()) // one-time (replay-protected)
.setIssuedAt()
.setExpirationTime("120s") // <= registered max age (120s)
.sign(key);
res.set("Cache-Control", "no-store");
res.status(201).json({ token, nonce, expiresIn: 120 });
});See Recognize signed-in customers for the full claim table, the never-put-in-a-token list, and the key-rotation order. The short version: iss, aud, your workspace claim and sub (your unchanging internal id, never an email or session id) are required on every mint; nonce is echoed back verbatim; jti is one-time; exp - iat is at most 120 seconds.
3. The one thing a hand-rolled ES256 JWT gets wrong
All four samples do the ES256 DER → raw r‖s signature conversion explicitly. Skip it and a library that expects the raw form refuses every token your endpoint mints, and the refusal reads exactly like a wrong key rather than a wrong encoding — which is why every sample spells the conversion out instead of trusting a library default.
4. Publish the key, never the endpoint's private half
Publish the public key at /.well-known/jwks.json with a kid and a short cache lifetime. If the object about to be served carries a d field, stop: that is the private scalar, and none of the four samples will print it for you. Rotate by publishing the new key beside the old one, waiting past your cache lifetime, switching which kid signs, waiting one more token lifetime, then removing the old key — never the reverse order.
5. Prove the endpoint, not just the file
A copied file with a wrong claim, a missing Cache-Control: no-store, or a clock five minutes off from real time all look identical from the outside: a customer stuck as a guest. Run the CLI (node v2/scripts/identity-conformance.mjs --host <your-host> --json) against the live endpoint before wiring any platform to it — it checks the response your language actually produces, not the sample you started from.
6. Let the diagnostics sign in as a test account
Your mate can run its full diagnostics as one of your own test accounts (Console → Connections → Test account, or verify_tenant_private_tools). The endpoint above cannot serve that: it requires the customer's own session, and a check is a server with none. You publish nothing secret for this and share no key: the check presents a short-lived signed assertion, you verify it against our published keys with any standard JOSE library, and you answer with the same launch token the endpoint already mints.
The check POSTs to the same endpoint with an Authorization: Bearer assertion instead of a session:
| Format | JWT (RFC 7519), signed ES256, kid in the header |
| Our keys | https://busymate.ai/.well-known/jwks.json |
iss | https://busymate.ai |
aud | your identity provider's issuer origin, so an assertion minted for you is inert everywhere else |
sub | the login of the test account registered in your workspace |
purpose | test-account-sign-in, valid nowhere else |
tenant_id | your registered workspace claim value |
nonce | the nonce in the body; echo it back |
jti, iat, exp | one-time id; at most 60 seconds old |
The one rule that matters: a valid assertion means the platform is asking on behalf of the account named in sub. It does not mean "mint for whoever sub says". Check sub against your own list of review logins and refuse anything else, or you have handed over the ability to impersonate your customers.
// Add this ONE branch to the endpoint from step 3. Everything else there
// stays exactly as it is: your customers' browsers keep using the session arm.
import { createRemoteJWKSet, jwtVerify } from "jose";
// Our published keys. Cache the key set — do not fetch it per request.
const BUSYMATE_JWKS = createRemoteJWKSet(
new URL("https://busymate.ai/.well-known/jwks.json"),
);
// The review logins YOUR product recognises. THIS LIST IS THE SECURITY BOUNDARY:
// a valid assertion means "busymate.ai is asking on behalf of <sub>", never
// "mint for whoever <sub> says". Anything not in here must be refused, or you
// have handed us the ability to impersonate your customers.
const REVIEW_LOGINS = new Set(["bmaireview@your-assistant.example"]);
app.post("/api/bmai/identity", async (req, res) => {
const auth = req.headers.authorization ?? "";
if (auth.startsWith("Bearer ")) {
let claims;
try {
({ payload: claims } = await jwtVerify(auth.slice(7), BUSYMATE_JWKS, {
issuer: "https://busymate.ai",
// YOUR provider's issuer origin. An assertion minted for someone else
// fails here, which is what audience binding is for.
audience: "https://YOUR-PRODUCT-DOMAIN",
algorithms: ["ES256"],
maxTokenAge: "60s",
}));
} catch {
return res.status(401).json({ signedIn: false, reason: "invalid_assertion" });
}
// Single-purpose: this token is valid for nothing else, and nothing else
// is valid here.
if (claims.purpose !== "test-account-sign-in") {
return res.status(403).json({ signedIn: false, reason: "wrong_purpose" });
}
if (typeof claims.sub !== "string" || !REVIEW_LOGINS.has(claims.sub)) {
return res.status(403).json({ signedIn: false, reason: "not_a_review_account" });
}
const user = await findUserByLogin(claims.sub); // YOUR lookup
if (!user) {
return res.status(403).json({ signedIn: false, reason: "not_a_review_account" });
}
// The SAME mint your browser arm already calls, with the SAME nonce rules.
return res.json(await mintLaunchToken(user, String(claims.nonce ?? req.body.nonce)));
}
// ── unchanged: your customers' own session ──────────────────────────────
const user = await currentUser(req);
if (!user) return res.status(401).json({ signedIn: false });
return res.json(await mintLaunchToken(user, req.body.nonce));
});Before calling it done, check that a request with no Authorization behaves exactly as before, and that a token signed with your own key, an assertion for another aud, one naming a real customer who is not a review login, and one older than 60 seconds are all refused. A valid assertion returns the same { token, nonce, expiresIn } your browser arm returns, and Console → Connections → Test account → Run full check reports sign_in green. If our JWKS answers 404, this arm is not available yet.
Already expose sign-in as a tool? Then you need none of this: the check calls your own sign_in on a connected server with the account's login and password, or its code if you registered the account as One-time code (fixed review code). Either arm is enough, and the Console names the one your workspace is missing.
Verify
POSTyour endpoint your own session cookie and a fresh nonce; it returns201with{ token, nonce, expiresIn }andCache-Control: no-store.- The same request signed out, or with no session, is refused — never a token for nobody.
- Two calls in a row return two different
jtivalues and two different signatures, never a cached pair. - Run the CLI against the endpoint; every claim, TTL, nonce-echo and clock-skew cell passes before any platform guide above is wired to it.
Next
- Recognize signed-in customers — the full claim contract and the key-rotation order.
- Fix a signed-in customer who shows as a guest — symptom-first fixes when a cell fails.
- In-app AI support for iOS and Android — the platforms that call this endpoint from a native bridge.
Questions
Why does the reference sample show Node when my API is in a different language?
The claim set and response shape are identical across all four; Node is shown once as the shape, and the PHP, Python and Go files build the same result in their own syntax.
What happens if I skip the DER-to-raw conversion?
Most ES256 verifiers, including the one this platform runs, refuse the token outright. It reads as a rejected key, not as an encoding mistake, which is why the samples handle it for you.
Can I sign with more than one algorithm?
Yes, if you register more than one in your provider setup; every mint must use an algorithm in that registered list, checked against your published JWKS.
Do I need a separate signing key per platform I integrate?
No. One key pair, one JWKS entry, and every platform's guide above calls the same mint endpoint your key signs.