Scoped tokens for private content
Mint a short-lived, per-user token on your own server so the browser never holds a key that can see everything.
A scoped token lets a browser search a specific slice of your data — one partition, a set of indexes, an extra filter — without ever holding a key broad enough to search everything. You mint it on your own server, per request or per session, from a key that never reaches the browser.
Claims
A scoped token is an HMAC-signed claim set:
type ScopedTokenClaims = {
keyId: string;
indexes: string[];
partition: string | null;
filters: Filter[];
exp: number;
ratePerMinute: number | null;
};| Claim | Meaning |
|---|---|
keyId | Which of your keys signed this token (used to look up the derived secret) |
indexes | The only indexes this token can search |
partition | If set, every search is pinned to this one partition — see Partitions |
filters | Extra filters applied to every search on top of whatever the caller sends |
exp | Unix timestamp in seconds after which the token is rejected (the JWT convention — a value in milliseconds is rejected outright) |
ratePerMinute | An optional rate limit tighter than the underlying key's |
The signing secret
Minting a token means signing the ScopedTokenClaims shape above with HMAC-SHA256. The secret for that HMAC is not your raw search key — it is a signing secret, returned exactly once, alongside the key itself, when you create a search key:
{
"key": { "id": "suokey_8f2c", "type": "search", "display": "suo_search_…7f3a" },
"secret": "suo_search_YOUR_KEY",
"signingSecret": "3q0F...base64url"
}secret is the public bearer key — safe to ship to the browser as api-key on <suo-search> or in createSuoClient({ apiKey }). signingSecret is the opposite: it never leaves your server. Store it the same way you would a database credential, next to (but distinct from) the key it was issued with.
Sign scoped tokens with @suo/client/server, which exports exactly one signer and takes the signing secret, never the account's master secret:
import { signScopedToken } from '@suo/client/server';
import type { ScopedTokenClaims } from '@suo/client/server';
const claims: ScopedTokenClaims = {
keyId: 'suokey_8f2c',
indexes: ['library'],
partition: userId,
filters: [],
exp: Math.floor(Date.now() / 1000) + 5 * 60,
ratePerMinute: null,
};
const token = await signScopedToken(signingSecret, claims);The API verifies a scoped token by re-deriving the same signing secret from its own master secret and keyId at request time — it is never stored, on either side. A leaked scoped token exposes only what its claims allow, for as long as exp allows it, and cannot be used to mint another token. A leaked signing secret is more serious — it can sign tokens for any claims — so rotate it the same way you would rotate the key: there is no separate rotation endpoint, creating a new search key issues a new signing secret.
Only a search key gets a signingSecret; admin and write keys never carry one, since a scoped token only ever narrows a search key.
pnpm suo keys create does not print signingSecret yet — call POST /1/keys directly (see API keys) until the CLI catches up, and copy it from the JSON response the same way you would secret.
Using one
Pass the token instead of a search key:
<suo-search host="https://api.usesuo.com" scoped-token="{{token}}" index-name="library"></suo-search>const client = createSuoClient({ host: 'https://api.usesuo.com', scopedToken: token });Expiry and rotation
Keep exp short — minutes, not days — and mint a new token on each page load or session refresh rather than caching one long-term. A short expiry means a token that leaks (in a browser history, a shared screenshot, a log) stops being useful quickly.
When you do not need one
If your content is public and not partitioned, a search key with an index and referrer restriction (see API keys) is enough — scoped tokens exist for the case a shared key cannot cover safely: content that must be isolated per user.