Tenant Tokens
A tenant token lets one of your customers search from the browser and see only its own documents. Your server signs the token from one of your search keys, with no call to Headband. The token names the indexes the customer may search, a filter Headband forces on every search of each one, and when it expires. Headband checks the signature and applies the filter on its side, AND-ed with any filter the browser sends, on Meilisearch, Typesense, Elasticsearch and Visor alike. The browser can narrow its results but never widen them.
How it works
- Create a search key for the branch. Keep it on your server: it is the secret your tokens are signed with, so it never goes to a browser.
- Note the key's id. The dashboard shows it when the key is created, and Copy key id in the keys list copies it.
POST /v1/keysandheadband keys listreturn it asid. - When a customer signs in to your app, sign a token for that customer on your server and send it to the browser.
- The browser searches with the token in place of a key:
Authorization: Bearer <token>.
Sign a token in Node
createTenantToken is in @headband/react/server. It uses Web Crypto, so it runs on Node 20 and later, Deno, Bun and edge runtimes. Import it only in server code.
import { createTenantToken } from "@headband/react/server";
// GET /api/search-token, after your own sign-in check.
export async function searchTokenFor(customer: { tenantId: number }) {
return createTenantToken({
searchKey: process.env.HEADBAND_SEARCH_KEY!, // hb_src_...
keyId: process.env.HEADBAND_SEARCH_KEY_ID!, // the key's id
searchRules: {
invoices: { filter: { tenant_id: customer.tenantId } },
customers: { filter: { tenant_id: customer.tenantId } },
},
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // one hour
});
}import { headband } from "@headband/react";
const { token } = await fetch("/api/search-token").then((r) => r.json());
const client = headband("https://headband.dev", token);
const results = await client.search("invoices", "overdue", {
filter: 'status = "open"', // AND-ed with tenant_id = <the customer's id>
});Pass the filter as an object, as above, and the helper quotes each value for you. That is the safe way to put an id from your database into a filter. A string such as "tenant_id = 42" works too, if you build it yourself. An array means "any of": { region: ["eu", "us"] } becomes region IN ["eu", "us"].
Token format
A tenant token is a JWT signed with HS256, so any JWT library can make one. The signing key is not the search key itself. It is the HMAC-SHA256 of the text headband-tenant-token-v1, keyed with the search key: 32 bytes. Headband keeps that derived secret encrypted, never the search key.
{
"keyId": "<the search key's id>",
"searchRules": {
"invoices": { "filter": "tenant_id = 42" },
"customers": { "filter": ["tenant_id = 42", "archived = false"] },
"help_articles": null
},
"exp": 1793000000
}signing_key = HMAC_SHA256(key = "<your hb_src_ key>", message = "headband-tenant-token-v1")
token = JWT_HS256(header = {"alg": "HS256", "typ": "JWT"}, payload, signing_key)| Claim | Required | What it is |
|---|---|---|
keyId | Yes | The id of the search key that signed it. |
searchRules | Yes | The indexes the token may search, 1 to 100, each with its rule. A rule is { "filter": ... } with a string or an array of strings (AND-ed), or {} or null for no forced filter. Exact index uids only: "*" and patterns are refused. |
exp | Yes | When the token stops working, in unix seconds, at most 30 days away. A value in milliseconds is refused. |
nbf, iat | No | Checked when present: not valid before nbf, and iat may not be in the future (60 seconds of clock skew allowed). |
What Headband enforces
- Every search of an index the token names gets the token's filter, AND-ed with the filter the browser sends. A browser filter of
tenant_id = 99under a token for tenant 42 finds nothing. - Any other index answers
404 index_not_found, exactly like an index that does not exist. - A token can only narrow its key. If the search key is limited to chosen indexes, the token reaches only the indexes it names that the key also reaches.
- Under a token, the browser's filter must use the portable syntax:
field = "value",!=,>,>=,<,<=,field IN ["a", "b"],field EXISTS,AND,ORand parentheses, as a string or an array of strings. A filter in an engine's own syntax, or a value holding quotes, backslashes, backticks, parentheses, brackets or commas, answers400 invalid_filterand the search does not run. Each filter is at most 2048 characters. - The token's own filter follows the same syntax, and a little more strictly: a value may not contain the words AND or OR between spaces, and a number must be written as itself (
7, not007or7.0; quote it to compare it as text). Range comparisons (>,>=,<,<=) take a number only, such as a unix timestamp. A token whose filter breaks a rule is refused whole. - Meilisearch and Visor compare text values without regard to case, so
tenant = "acme"also matches"ACME"there. Use tenant ids that differ by more than case, or numeric ids. - The filter's fields must be filterable on the index, as for any filter. On Meilisearch and Visor, add them to
filterableAttributes. A field the engine cannot filter on is an error, never an unfiltered search. - Revoking the search key stops its tokens within seconds. There is no way to revoke a single token, so keep tokens short-lived.
Where tokens work
Tenant tokens work on POST /v1/search and POST /v1/multi-search. In a multi-search, each query gets the filter of its own index, and a query on an index the token does not name answers index_not_found in its place while the others run. Every other endpoint answers 401 tenant_token_not_accepted, and so does a dedicated engine's own endpoint (401 invalid_credentials). Click events (/v1/relevance/events) and /v1/warm do not take a token yet.
Errors
| Status | Code | When |
|---|---|---|
| 401 | tenant_token_expired | exp has passed. |
| 401 | invalid_tenant_token | The token is malformed, its claims break a rule above, or its signature does not match a live search key (a revoked key, another key's secret, or a changed payload). |
| 401 | tenant_token_key_not_ready | The search key was made before tenant tokens existed and has not been used since. Make one request with the key itself (any search), or make a new key. |
| 401 | tenant_token_not_accepted | The endpoint does not take tenant tokens. |
| 400 | invalid_filter | The browser's filter breaks the rules above. Nothing was searched. |
| 404 | index_not_found | The token, or its key, does not reach that index. |
Keep the search key on your server