Токены поиска с ограниченной областью
Короткоживущие HMAC-подписанные токены поверх API-ключа с фильтром арендатора — безопасно класть в браузер.
Scoped-токены поиска
Scoped-токен поиска — это то, что вы отдаёте в браузер, когда каждый пользователь должен видеть только свои данные. Сервер подписывает токен фильтром пользователя и коротким TTL, браузер шлёт его вместо исходного API-ключа, а серверная сторона на каждом запросе принудительно применяет фильтр.
Scoped-токен никогда не даёт больше, чем родительский API-ключ. Он только сужает видимость.
Когда применять
Используйте scoped-токен, если все условия выполнены:
- Хотите ходить в поиск прямо из браузера.
- Пользователь у вас аутентифицирован, и вы знаете его tenant ID.
- Нужно ограничить каждый запрос данными именно этого арендатора — по
userId,accountId,companyIdи т. п.
Если нужен только публичный поиск по каталогу без ограничения по пользователю — достаточно search-ключа с allow-list по Origin. См. API-ключи и Allow-list по Origin.
Анатомия токена
Scoped-токен выглядит так:
ss_scoped_<base64url payload>.<base64url HMAC-SHA-256 signature>Декодированный payload — JSON:
{
"keyId": "ck_…",
"parentRawKey": "ss_search_…",
"filterBy": "tenantId:=org_abc",
"exp": 1730000000
}keyId— строка родительскогоSearchApiKey, для корреляции в журнале аудита.parentRawKey— API-ключ, который оборачивает токен. Ведёт себя как этот ключ, но…filterBy— …этот фильтр AND-комбинируется с тем, что шлёт приложение.exp— Unix epoch seconds. После истечения верификация падает сinvalid_or_expired_scoped_token(HTTP 401).
Подпись — HMAC-SHA-256(BETTER_AUTH_SECRET, payload). Подделка требует либо серверный секрет, либо взлом SHA-256.
В токене зашит исходный родительский API-ключ как base64url-поле. Ключ виден только тем
клиентам, которым вы выпустили токен, но и сам токен — секретный материал: не логируйте
его и не пишите в query-параметры URL, где он попадёт в access-логи. Передавайте в заголовке
Authorization: Bearer.
Сужение фильтра — правило AND
Самое важное правило страницы. filterBy из токена всегда AND-комбинируется с filterBy приложения через серверный хелпер combineFilters():
final_filter = "(token.filterBy) && (application.filterBy)"Из этого следует:
- Scoped-токен никогда не расширяет видимость пользователя.
- Если из браузера прислать
filterBy: "tenantId:!=org_abc", токен сtenantId:=org_abcэто не «отменит» — AND даст пустой результат, а не расширение. - Даже когда родителем выступает
admin-ключ, обёрнутый в scoped-токен, на время жизни токена он ведёт себя как суженный.
Это enforced server-side. Флага, чтобы отключить, нет. См. Инвариант 4 в agents.md.
Выпуск токена
Выпускайте scoped-токены на сервере, никогда не в браузере. Для подписи нужен BETTER_AUTH_SECRET, который не должен покидать сервер.
Из oRPC-процедуры
// apps/saas (серверная сторона)
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 минут
});Из Hono-роута
// 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 });
});Использование из браузера
// 1. Получаем свежий токен у своего бэкенда при загрузке страницы или когда до exp осталось < 60 сек.
const { token } = await fetch("/api/scoped-token", { credentials: "include" }).then((r) =>
r.json(),
);
// 2. Передаём как обычный bearer-токен.
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: "кроссовки", filter_by: "in_stock:=true" }),
});Либо используйте @repo/search-client, который умеет рефрешить токен сам, если передать tokenProvider.
Какой TTL выбрать
Берите минимальный TTL, какой выдерживает ваш UI. Ориентиры:
| Поверхность | Рекомендуемый TTL |
|---|---|
| Личный кабинет залогиненного клиента | 5–15 минут |
| Публичный сайт за CDN | 1–5 минут |
| SPA-форма оформления заказа | 60 секунд |
Токены stateless — централизованного revocation-list нет. Способов «отозвать» scoped-токен два: (а) дождаться истечения, (б) отозвать родительский API-ключ — это инвалидирует все выпущенные из него токены. Планируйте соответственно.
Пример: поиск по организации
В каталоге документы вида { id, name, tenantId, price, stock }. Каждый пользователь должен видеть только документы своей организации.
Сервер (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 });
}Клиент:
// Пользователь шлёт `filter_by: "stock:>0"`. С токеном итоговый фильтр:
// (tenantId:=org_abc) && (stock:>0)
// Доступа к чужим документам не получит ни при каких комбинациях фильтров.Пример: поиск по пользователю
Та же идея, но более узко:
filterBy: `userId:=${session.user.id}`;Браузер может строить любые фильтры — результат всегда пересечение с userId:=… из токена.
Частые ошибки
- Выпуск на клиенте. HMAC-секрет должен оставаться на сервере. Подпись на клиенте = опубликовать секрет.
- Хранение токена в
localStorage. Он короткоживущий, но секретный. Лучше держать в памяти и обновлять через callback; если нужно —sessionStorage. - Длинный TTL «чтобы не возиться с рефрешем». Длинный TTL увеличивает blast radius украденного токена. Рефреш дешёвый.
- Заворачивание
admin-ключа. Scoped-токен сужает фильтры, но операции остаются те же. Заворачивайтеsearch-ключ.
См. также
- API-ключи — модель родительских ключей
- Изоляция арендаторов — обычно сужают именно по
organizationId - Allow-list по Origin — дополнительный слой для браузерных ключей