suo
Guides

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;
};
ClaimMeaning
keyIdWhich of your keys signed this token (used to look up the derived secret)
indexesThe only indexes this token can search
partitionIf set, every search is pinned to this one partition — see Partitions
filtersExtra filters applied to every search on top of whatever the caller sends
expUnix timestamp in seconds after which the token is rejected (the JWT convention — a value in milliseconds is rejected outright)
ratePerMinuteAn 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.

On this page