--- title: Browser tokens description: Turn a publishable key into a short-lived, origin-bound access token that a storefront can use to call discovery and send analytics events. slug: authentication/browser-tokens docKind: guide hub: luigisbox-ai --- A publishable key is the credential you can put in page source. It is not a secret, and it is not sufficient on its own: the browser exchanges it for a short-lived access token by solving a proof-of-work challenge. The challenge is one of several bot-control layers in front of the API — see [Bot control](/api-basics/bot-control/) for the rest, and for why a server-side integration must not use this flow. ## What a browser token can do Exactly two things, on exactly one channel, from an origin you registered: - **Read discovery** — search, collections, recommendations, facets, object lookup, variants. - **Write analytics events** for that same channel. A browser token cannot index or delete catalog content, cannot read catalog configuration, cannot touch organizations, catalogs, channels, surfaces or campaigns, and cannot request ranking diagnostics. The token's permissions are **derived from the key** — the channel it is bound to, plus which of the two audiences you asked for. Your account's own grants are not consulted. Requesting any other audience yields a token with an empty permission set. ## What a publishable key is bound to | Bound to | Effect | |---|---| | One channel | Results and events for that channel only. A request naming a different channel is rejected, even if your account owns it. | | A list of origins | A token request from an unregistered origin is rejected, and the issued token stays bound to the origin it was minted for | | One region and environment | A test key cannot reach production, and a European key cannot reach the US region | The key looks like `pub_live_eu1_CNA1QBVS7PQzKNDn`: environment, region, then random characters. Ask Luigi's Box for a key per storefront, and register every origin the storefront is served from — including staging hostnames. Because a token minted from a publishable key is a browser token, it never sees fields in the `private:` namespace. Anything you do not want a shopper to be able to read must live there. See [Keeping data out of the browser](/concepts/catalog-object-model/#keeping-data-out-of-the-browser). ## The exchange, in three steps 1. **Ask for a challenge.** `GET /browser-challenge?api_key=` returns a `nonce`, a `difficulty` and an `expires_in`. 2. **Solve it.** Find a number whose SHA-256 hash, appended to the nonce, starts with `difficulty` zero bits. This normally takes 50–200 ms. 3. **Trade it for a token.** `POST /browser-token` with the key, the nonce, your solution and the channel. You get back an `access_token` and its `expires_in`. Both calls go to the auth host, not the API host. ### Ask for a challenge ```bash curl 'https://auth.eu1.luigisbox.ai/browser-challenge?api_key=pub_live_eu1_CNA1QBVS7PQzKNDn' \ -H 'Origin: https://shop.example.com' ``` The response: ```json { "nonce": "eyJpcCI6IjIwMy4wLjExMy43Iiwi….3f9c2a…", "difficulty": 20, "expires_in": 120 } ``` The nonce is signed and bound to the requesting address, the key and the origin, so it can only be redeemed by the browser that asked for it, from the same page, within `expires_in` seconds. ### Solve it Hash `nonce + solution` and count the leading zero bits of the digest: ```js async function solveChallenge(nonce, difficulty) { const encoder = new TextEncoder(); for (let solution = 0; ; solution += 1) { const digest = new Uint8Array( await crypto.subtle.digest('SHA-256', encoder.encode(nonce + solution)), ); if (leadingZeroBits(digest) >= difficulty) return solution; } } function leadingZeroBits(bytes) { let bits = 0; for (const byte of bytes) { if (byte === 0) { bits += 8; continue; } return bits + Math.clz32(byte) - 24; } return bits; } ``` Run this in a Web Worker to keep the main thread free. ### Trade it for a token ```js const response = await fetch('https://auth.eu1.luigisbox.ai/browser-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: 'pub_live_eu1_CNA1QBVS7PQzKNDn', pow_nonce: nonce, pow_solution: solution, channel_id: 'lbn_4hj9tv', audience: 'https://api.eu1.luigisbox.ai/discovery', }), }); const { access_token, expires_in } = await response.json(); ``` `channel_id` is required and must be the channel the key is bound to. `audience` names the products the token is for — `…/discovery` to search and browse, `…/events` to report behaviour — and it accepts an **array**, so one token can cover both: ```json { "api_key": "pub_live_eu1_CNA1QBVS7PQzKNDn", "pow_nonce": "…", "pow_solution": 918273, "channel_id": "lbn_4hj9tv", "audience": [ "https://api.eu1.luigisbox.ai/discovery", "https://api.eu1.luigisbox.ai/events" ] } ``` A storefront normally holds one token covering both. These are the only two audiences a browser token can hold. Naming a different one — indexing, platform configuration, object history — is not an error, but the token comes back with no permissions, so every call made with it is refused. ## A reusable token provider Wrap the whole exchange in something that hands out a token and refreshes it a little early, and reuse that across your integration. ```js function createTokenProvider({ apiKey, channelId, audience, authHost }) { let cached = null; async function mint() { const challengeUrl = `${authHost}/browser-challenge?api_key=${encodeURIComponent(apiKey)}`; const { nonce, difficulty } = await fetch(challengeUrl).then((r) => r.json()); const solution = await solveChallenge(nonce, difficulty); const { access_token, expires_in } = await fetch(`${authHost}/browser-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, pow_nonce: nonce, pow_solution: solution, channel_id: channelId, audience, }), }).then((r) => r.json()); // Refresh 30s early so an in-flight request never races expiry. return { token: access_token, expiresAt: Date.now() + (expires_in - 30) * 1000 }; } return async function getToken() { if (!cached || Date.now() >= cached.expiresAt) cached = await mint(); return cached.token; }; } ``` Mint the first token while the page is loading, not on the first keystroke, so the challenge does not delay the first request. ## Session continuity `X-Lbx-Visitor-Id` travels with every discovery request and every analytics event, and it is not part of authentication — it is a stable identifier for the browser that your integration generates and persists. Re-minting a token does **not** reset it. Keep the same visitor identifier across any number of token refreshes; it is what joins a shopper's API calls to their analytics events. See [Requests and responses](/api-basics/requests-and-responses/#identifying-the-shopper). ## When the exchange fails | Symptom | Cause | |---|---| | `403` on `/browser-token`, message mentions origin | The page's origin is not registered for the key | | `403`, message mentions the nonce or IP | The challenge was redeemed from a different address, or reused | | `403`, message mentions difficulty | The solution does not satisfy the challenge — check that you hash `nonce + solution` as a string | | Nonce expired | More than `expires_in` seconds elapsed; ask for a fresh challenge | | `403` on a discovery or events call with a fresh token | The `channel_id` did not match the key's channel, or the audience you are calling was not in the token request | | The token works, but `private:` fields are missing | Expected — browser tokens never see them | The challenge and the token call must come from the same address, so a DNS or CDN change between the two can fail the exchange within the nonce's lifetime. Retry once. ## See also - [Authentication overview](/authentication/overview/) — audiences and permissions - [Browser quickstart](/start-here/browser-quickstart/) — this flow wired to a search box - [Server-to-server tokens](/authentication/server-to-server/) — the backend equivalent