AACsearch
Security & Compliance

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:

  1. You want to call search directly from the browser.
  2. The user is authenticated on your side, so you can derive their tenant ID.
  3. 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 parent SearchApiKey row, 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 their exp fail verification with invalid_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's tenantId:=org_abc — the AND-combine makes the result empty, not broader.
  • Even an admin parent 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:

SurfaceRecommended TTL
Logged-in customer dashboard5–15 minutes
Public site behind a CDN1–5 minutes
Single-page checkout flow60 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.

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.

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, use sessionStorage.
  • Using a long-lived token "to avoid refresh complexity". Long TTLs amplify the blast radius of a stolen token. Refresh is cheap.
  • Wrapping an admin key. Scoped tokens narrow filters, but the parent key still owns the operations it grants. Mint scoped tokens from search-only parent keys.

See also

On this page