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

  1. 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.
  2. 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/keys and headband keys list return it as id.
  3. When a customer signs in to your app, sign a token for that customer on your server and send it to the browser.
  4. 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.

server.ts
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
  });
}
browser
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.

payload
{
  "keyId": "<the search key's id>",
  "searchRules": {
    "invoices": { "filter": "tenant_id = 42" },
    "customers": { "filter": ["tenant_id = 42", "archived = false"] },
    "help_articles": null
  },
  "exp": 1793000000
}
any language: the signing key
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)
ClaimRequiredWhat it is
keyIdYesThe id of the search key that signed it.
searchRulesYesThe 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.
expYesWhen the token stops working, in unix seconds, at most 30 days away. A value in milliseconds is refused.
nbf, iatNoChecked 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 = 99 under 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, OR and 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, answers 400 invalid_filter and 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, not 007 or 7.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

StatusCodeWhen
401tenant_token_expiredexp has passed.
401invalid_tenant_tokenThe 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).
401tenant_token_key_not_readyThe 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.
401tenant_token_not_acceptedThe endpoint does not take tenant tokens.
400invalid_filterThe browser's filter breaks the rules above. Nothing was searched.
404index_not_foundThe token, or its key, does not reach that index.

Keep the search key on your server

Anyone with the search key can sign any token, for any customer. Use a separate search key for signing tokens and never put it in a web page. A plain search key in the browser bypasses tenant tokens entirely: see What a Search Key Can Read.