Scoped search tokens
Short-lived HMAC-signed tokens that wrap an API key with a tenant filter — safe to put in a browser.
Scoped search tokens
A scoped search token is what you ship to a browser when each user must only see their own data. The server signs a token with the user's filter and a short expiry, the browser sends it instead of the raw API key, and the server side enforces the filter on every search.
A scoped token never grants more than the parent API key already grants. It only narrows what that key can see.
When to use one
Use a scoped token when all of the following are true:
- You want to call search directly from the browser.
- The user is authenticated on your side, so you can derive their tenant ID.
- You need every search query to be limited to that tenant — by
userId,accountId,companyId, etc.
If you only need a public catalog search with no per-user restriction, an origin-restricted search API key is enough. See API keys and Origin allow-list.
Anatomy of a token
A scoped token looks like this:
ss_scoped_<base64url payload>.<base64url HMAC-SHA-256 signature>The decoded payload is a JSON object:
{
"keyId": "ck_…",
"parentRawKey": "ss_search_…",
"filterBy": "tenantId:=org_abc",
"exp": 1730000000
}keyId— the parentSearchApiKeyrow, for audit-log correlation.parentRawKey— the API key the token wraps. Treated identically to passing that key directly, except…filterBy— …this filter is AND-combined with whatever the application sends.exp— Unix epoch seconds. Tokens past theirexpfail verification withinvalid_or_expired_scoped_token(HTTP 401).
The signature is HMAC-SHA-256(BETTER_AUTH_SECRET, payload). Forgery requires either the server secret or breaking SHA-256.
The token contains the parent raw API key as a base64url-encoded field. That key is only
exposed to clients you mint tokens for, but treat the token itself as secret material:
never log it, and never write it to a URL query string where it will end up in access logs.
Pass it in the Authorization: Bearer header.
Filter narrowing — the AND-combine rule
This is the most important rule on the page. The token's filterBy is always AND-combined with the application's filterBy via the server's combineFilters() helper:
final_filter = "(token.filterBy) && (application.filterBy)"This means:
- A scoped token can never widen what the user sees.
- Sending
filterBy: "tenantId:!=org_abc"from the browser does not bypass the token'stenantId:=org_abc— the AND-combine makes the result empty, not broader. - Even an
adminparent key, when wrapped in a scoped token, behaves as a narrowed key for the duration of that token.
This is enforced server-side. There is no flag to disable it. See Invariant 4 in agents.md.
Minting a token
Mint scoped tokens on your server, never in the browser. The minting code needs BETTER_AUTH_SECRET, which never leaves the server.
From an oRPC procedure
// apps/saas (server-side)
import { issueScopedSearchToken } from "@repo/api/search/scoped-token";
const { token, expiresAt } = issueScopedSearchToken({
keyId: parentKey.id,
parentRawKey: parentKey.rawKey,
filterBy: `tenantId:=${session.user.organizationId}`,
expiresInSeconds: 60 * 15, // 15 minutes
});From a Hono route
// packages/api/.../route.ts
app.post("/api/scoped-token", async (c) => {
const session = await getSession(c);
if (!session) return c.json({ error: "unauthorized" }, 401);
const { token, expiresAt } = issueScopedSearchToken({
keyId: parentKey.id,
parentRawKey: parentKey.rawKey,
filterBy: `userId:=${session.user.id}`,
expiresInSeconds: 60 * 5,
});
return c.json({ token, expiresAt });
});Using a token from the browser
// 1. Fetch a fresh token from your own server when the page loads, or when the current one
// is < 60 seconds from expiry.
const { token } = await fetch("/api/scoped-token", { credentials: "include" }).then((r) =>
r.json(),
);
// 2. Pass it like any other bearer token.
const res = await fetch("https://app.aacsearch.com/api/v1/indexes/products/search", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ q: "running shoes", filter_by: "in_stock:=true" }),
});Or use @repo/search-client, which handles refresh automatically when you give it a tokenProvider callback.
Choosing a TTL
Pick the shortest TTL your UI can tolerate. Suggested ranges:
| Surface | Recommended TTL |
|---|---|
| Logged-in customer dashboard | 5–15 minutes |
| Public site behind a CDN | 1–5 minutes |
| Single-page checkout flow | 60 seconds |
Tokens are stateless — there is no central revocation list. The only ways to "revoke" a scoped token are: (a) wait for its expiry, or (b) revoke the parent API key, which invalidates all tokens minted from it. Plan accordingly.
Worked example: per-organization search
Catalog has documents like { id, name, tenantId, price, stock }. Each user must only see their organization's documents.
Server (Next.js Route Handler):
import { issueScopedSearchToken } from "@repo/api/search/scoped-token";
import { getSession } from "@auth/lib/server";
export async function POST() {
const session = await getSession();
if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
const parentKey = await getParentApiKeyForOrganization(session.user.organizationId);
const { token, expiresAt } = issueScopedSearchToken({
keyId: parentKey.id,
parentRawKey: parentKey.rawKey,
filterBy: `tenantId:=${session.user.organizationId}`,
expiresInSeconds: 60 * 10,
});
return Response.json({ token, expiresAt });
}Client:
// User sends `filter_by: "stock:>0"` — combined with the token, the final filter is:
// (tenantId:=org_abc) && (stock:>0)
// They cannot see another organization's documents under any combination of filters.Worked example: per-user document search
Same idea, finer scope:
filterBy: `userId:=${session.user.id}`;The browser can still build any filter it wants — but every result will be the union of those filters and the token's userId:=…, so the user only sees their own documents.
Common mistakes
- Minting on the client. The HMAC secret must stay on the server. If you mint client-side, you have effectively published the secret.
- Storing the token in
localStorage. It is short-lived but still secret. Prefer in-memory state with a refresh callback; if you must persist, usesessionStorage. - Using a long-lived token "to avoid refresh complexity". Long TTLs amplify the blast radius of a stolen token. Refresh is cheap.
- Wrapping an
adminkey. Scoped tokens narrow filters, but the parent key still owns the operations it grants. Mint scoped tokens fromsearch-only parent keys.
See also
- API keys — the parent key model
- Tenant isolation — the org-level filter you typically scope to
- Origin allow-list — additional defense for any browser-facing key