Private-mode user tokens
How to mint short-lived JWTs that prove an end-user is allowed to submit feedback for your App.
Why private mode?
Public mode lets anyone with the App's public key submit feedback. That's fine for many apps (especially marketing pages), but if you want to:
- Restrict feedback to logged-in users only
- Carry stable identity (your user id, email, plan) into every report
- Throttle per-user instead of per-IP
- Keep arbitrary public traffic from spamming the AI agent
...use private mode. Your backend signs a short-lived JWT, the widget includes it in X-Userz-User-Token on every submission, and we verify it server-side.
Recommended path: @userz-ai/node
The easiest way is the helper from the @userz-ai/node package. See that page for the full reference; the short version:
import { mintUserToken } from '@userz-ai/node';
const token = await mintUserToken({
appId: process.env.USERZ_APP_ID!,
signingSecret: process.env.USERZ_APP_SIGNING_SECRET!,
sub: req.user.id,
ctx: { email: req.user.email },
});Manual JWT construction
If you can't use Node, for example, signing from a Go or Python backend, produce a standard HS256 JWT with the following shape:
Header
{ "alg": "HS256", "typ": "JWT" }Payload
{
"sub": "user-id-in-your-system", // required
"app": "<24-char-app-id>", // required, must match the App's id
"ctx": { "email": "...", ... }, // optional, ≤2 KB JSON
"iat": 1761000000, // required (unix seconds)
"exp": 1761003600 // required, ≤24 hours from iat
}Signature
HMAC-SHA256(signingSecret, base64url(header) + "." + base64url(payload)). The signing secret is the App's per-App secret shown in the dashboard (encrypted at rest on our side). It's binary, accept hex or base64 input and decode before using as the HMAC key.
Lifetime + rotation
- Default token TTL: 1 hour. Maximum: 24 hours. Minimum: 60 seconds.
- Rotate the App's signing secret from App settings → Rotate private key if you suspect compromise. All outstanding tokens become invalid immediately; you'll need to re-mint.
- Refresh tokens client-side as their
expnears, yourgetUserTokeninUserzProvidercan return a fresh value from your auth store on every call.
Verification (server-side)
Userz applies these checks before accepting the submission:
- Algorithm is HS256 (alg=none and asymmetric algos are rejected, no algorithm-confusion attack).
- Signature verifies with the App's signing secret, decrypted in memory only.
- exp in the future, iat in the past.
- app claim equals the App referenced by the request's
publicKey. Tokens for App A cannot be reused on App B.
sub values, impersonating any of your users.POST /v1/tokens/mint (server-side option)
For backends that prefer not to handle JWT signing locally, or for backends in languages without a Userz SDK, issue an HTTP call to mint instead. Authenticated with an sk_… API key; the server signs with the App secret on the way out so no service except Userz itself ever holds the signing key.
curl -X POST "https://api.userz.ai/v1/tokens/mint?appId=65fa1f3e8a1e5f2d9c1a5c01" \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"sub": "user-id-in-your-system",
"ctx": { "email": "[email protected]", "plan": "pro" },
"expiresInSeconds": 3600
}'Query: appId (required, the 24-char hex App id). Body: sub (required), ctx (optional, ≤2 KB JSON), expiresInSeconds (default 3600, max 86400, min 60).
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expiresInSeconds": 3600
}Returns 404 app_not_found if the appId doesn't belong to the Org behind the sk_ key, cross-Org leakage is impossible. The typed client wraps this as userz.tokens.mint(...) (see @userz-ai/api).