Sitecore sent out an email yesterday that was actually a "kick the can" email, regarding a mandatory API key authorization for Search and Events APIs. Originally, this was supposed to go live mid-October 2026, and now it'll be mid-December. The first email went out a month or two ago, and support offered to change a non-prod environment to enforce the rule for testing. So we went through all that, and because I just saw someone on Slack noting the email and seeming concerned, I thought I'd offer up our solution.
Right now, because the Sitecore Search widget provider (from the React SDK to be clear) is client-based, it wants the Search customer key and API key in the NEXT_PUBLIC realm, so the browser can read it. But that does a bit of a no-no, exposing an API key. So I think this is designed to get around that, and our solution does that as well.
Basically, we created a new widget provider object, which takes the customer key and a generated, authorized API key, and feeds those in instead. When I say "we" I mean me and Claude Code - this was worked on with AI, but has been tested in a Sitecore Search non-prod environment that was configured to be "locked down" already, and we've had no issues with our search authorization.
So let's start with that widget, which we put in a "shared" folder application/nextjs/src/components:
"use client";
import { type ReactNode, useCallback } from "react";
import { WidgetsProvider, type Environment } from "@sitecore-search/react";
import { setCredentials } from "@sitecore-search/core";
import env from "@/lib/env";
// Refresh this long before the token's reported expiry, so a request that
// starts just before expiry never races a token that's about to go stale.
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
// Mount-time bootstrap value for WidgetsProvider's `apiKey` prop — needed only
// to pass the SDK's own synchronous "!apiKey && !serviceHost" config check at
// mount (see SitecoreSearchConfig.js); requestMiddleware overwrites it via
// setCredentials before any real request goes out, so this value itself is
// never sent to Sitecore. Shaped like the real `Bearer ${accessToken}` value
// (see buildAuthorizedApiKey below) rather than an arbitrary sentinel string,
// so it reads as "this slot, not yet populated" instead of a magic constant —
// matching how Sitecore's own Search SDK starter kit seeds the same prop from
// a token hook that can likewise start out empty (App.jsx: `apiKey={`Bearer
// ${accessToken}`}`, github.com/Sitecore/Sitecore-Search-JS-SDK-Starter-Kit).
const PLACEHOLDER_API_KEY = "Bearer pending";
/**
* The SDK's default "sitecore" connector puts whatever `apiKey` holds
* straight into the Authorization header verbatim (see
* @sitecore-search/data's headers.js) — it does not add a scheme itself. The
* raw 52-char API key is sent as-is; an access token needs the `Bearer `
* scheme added here instead, confirmed against the official starter kit's
* own `apiKey={`Bearer ${accessToken}`}` usage linked above.
*/
function buildAuthorizedApiKey(accessToken: string): string {
return `Bearer ${accessToken}`;
}
// Module-level cache shared by every SearchWidgetsProvider instance on the
// page: only the very first Search request anywhere on the page pays for the
// token fetch; every widget after that (and every later request from the
// same widget) reuses it until it's due to expire.
let cachedTokenPromise: Promise<string> | null = null;
let cachedTokenExpiresAt = 0;
interface AccessTokenResponse {
accessToken: string;
accessTokenExpiry: number;
}
async function ensureFreshAccessToken(): Promise<string> {
if (!cachedTokenPromise || Date.now() >= cachedTokenExpiresAt) {
cachedTokenPromise = fetch("/api/search/accessToken")
.then((res) => {
if (!res.ok) {
throw new Error(`search access-token request failed: ${res.status}`);
}
return res.json() as Promise<AccessTokenResponse>;
})
.then((data) => {
cachedTokenExpiresAt = Date.now() + data.accessTokenExpiry - REFRESH_MARGIN_MS;
return data.accessToken;
})
.catch((error) => {
// Don't cache a failure — the next request gets a clean retry.
cachedTokenPromise = null;
cachedTokenExpiresAt = 0;
throw error;
});
}
return cachedTokenPromise;
}
/**
* Drop-in replacement for `@sitecore-search/react`'s `WidgetsProvider`.
* Every Search-widget-backed component should use this instead of
* importing WidgetsProvider directly.
*
* Sitecore's Oct 15, 2026 Search/Events API auth-enforcement notice
* recommends frontend/JS-SDK implementations authorize with a short-lived
* access token rather than the raw (52-char, non-expiring) API key,
* specifically because the raw key ends up shipped in the client bundle —
* which is exactly what passing `env.SEARCH_API_KEY` straight into
* WidgetsProvider did. This wrapper fixes that without changing how any
* consumer renders: WidgetsProvider still mounts synchronously (with a
* placeholder apiKey — it only needs to be non-empty to pass the SDK's own
* config validation, see SitecoreSearchConfig.js's `!apiKey && !serviceHost`
* check), and `requestMiddleware` — which the SDK awaits immediately before
* *every* widget request, including the very first one (see
* @sitecore-search/data's widgetDataAdapter) — fetches/caches a real access
* token from /api/search/accessToken and swaps it into the SDK's config
* via `setCredentials` before that request's headers get built
* (getAPIHeaders() reads the config fresh at request time, not at mount
* time). No loading gate is needed: the existing per-widget loading state
* every consumer already renders covers the token fetch's added latency on
* the first request the same way it covers the search request itself.
*/
export default function SearchWidgetsProvider({ children }: { children: ReactNode }) {
const customerKey = env.SEARCH_CUSTOMER_KEY;
const requestMiddleware = useCallback(async () => {
const accessToken = await ensureFreshAccessToken();
setCredentials({
apiKey: buildAuthorizedApiKey(accessToken),
customerKey,
env: "prod" as Environment,
});
}, [customerKey]);
return (
<WidgetsProvider
env={"prod" as Environment}
customerKey={customerKey}
apiKey={PLACEHOLDER_API_KEY}
requestMiddleware={requestMiddleware}
>
{children}
</WidgetsProvider>
);
}
The "env" reference here is a file we have for calling up environment variables.
Now for the API call that's made to /api/search/accessToken. This is an app router project, so the file goes in application/nextjs/src/app/api/search/accessToken in a route.ts file:
// Serves a short-lived Sitecore Search access token to the browser so the
// long-lived Search API key stays server-only — see SearchAccessTokenService
// for why. Runs server-side so the raw key never has to be inlined into the
// client bundle the way NEXT_PUBLIC_SITECORE_SEARCH_API_KEY was.
import { NextRequest, NextResponse } from "next/server";
import env from "@/lib/env";
import { SearchAccessTokenService } from "@/lib/services/search/SearchAccessTokenService";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const apiKey = env.SEARCH_API_KEY;
const customerKey = env.SEARCH_CUSTOMER_KEY;
const token = await new SearchAccessTokenService(apiKey, customerKey).getAccessToken();
return NextResponse.json(token);
} catch (error) {
console.error("[api/search/accessToken] failed:", error);
return NextResponse.json(
{ error: "Failed to obtain a Sitecore Search access token" },
{ status: 502 },
);
}
}
Now the final part, the SearchAccessTokenService, which we put in application/nextjs/src/lib/services/search as a .ts file:
import env from "@/lib/env";
const TOKEN_ENDPOINT_HOST = "https://api.rfksrv.com";
// Sitecore's own defaults (doc.sitecore.com/search/.../get-an-access-token-and-a-refresh-token.html):
// 1 day for an access token, 1 week for a refresh token. We request the same
// values so the token behaves the way Sitecore's docs describe, even though
// this service never uses the refresh token itself — see the class doc below.
const DEFAULT_ACCESS_EXPIRY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
export class SearchAccessTokenError extends Error {}
export interface SearchAccessToken {
accessToken: string;
/** Milliseconds, as reported by the token endpoint (not a Unix timestamp). */
accessTokenExpiry: number;
}
/**
* Sitecore Search customer keys are "<companyId>-<domainId>" (e.g.
* "12345678-87654321"). The access-token endpoint's URL takes the domain id —
* the segment AFTER the hyphen. Confirmed against the installed
* @sitecore-search/data SDK's own `extractDomain` helper, which pulls this
* same segment to build the Search API's own request URL, so this isn't a
* guess independent of how the SDK itself addresses a domain.
*/
export function extractDomainId(customerKey: string): string {
const match = customerKey.match(/^(\d+)-(\d+)$/);
return match ? match[2] : "";
}
/**
* Exchanges the Sitecore Search API key for a short-lived access token so the
* long-lived key never has to reach the browser. Responds to Sitecore's
* Oct 15, 2026 mandatory-Authorization notice, which recommends frontend/
* JS-SDK implementations use an access token rather than the raw (52-char,
* non-expiring) API key specifically because the raw key ends up shipped in
* the client bundle.
*
* Requests both the "discover" scope (search/query requests) and the "event"
* scope (POST /event/{customerKey}/v4/publish — the click/facet-click
* tracking beacon @sitecore-search/data's RFKApiAdapter.trackEvent fires).
* A token minted with "discover" alone authorizes search requests fine but
* gets a 401 from the events-publish endpoint, since Sitecore Search access
* tokens carry per-API scopes rather than one blanket grant — see
* doc.sitecore.com/search/.../get-an-access-token-and-a-refresh-token.html's
* scope list.
*
* Deliberately re-mints from the API key on every call instead of persisting
* the endpoint's refresh token: Next.js API routes are stateless across
* invocations, and Sitecore's own docs describe "regenerate using the API
* key" as the supported path once a refresh token would otherwise be needed.
* The API key itself never expires, so re-minting is always available and
* this stays simple — no token store to build or secure.
*/
export class SearchAccessTokenService {
constructor(
private readonly apiKey: string = env.SEARCH_API_KEY,
private readonly customerKey: string = env.SEARCH_CUSTOMER_KEY,
) {}
async getAccessToken(): Promise<SearchAccessToken> {
if (!this.apiKey || !this.customerKey) {
throw new SearchAccessTokenError(
"Sitecore Search is not configured (missing API key or customer key).",
);
}
const domainId = extractDomainId(this.customerKey);
if (!domainId) {
throw new SearchAccessTokenError(
`Invalid Sitecore Search customer key: "${this.customerKey}". Expected "<companyId>-<domainId>".`,
);
}
let response: Response;
try {
response = await fetch(`${TOKEN_ENDPOINT_HOST}/account/${domainId}/access-token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
},
body: JSON.stringify({
scope: ["discover", "event"],
accessExpiry: DEFAULT_ACCESS_EXPIRY_MS,
refreshExpiry: DEFAULT_REFRESH_EXPIRY_MS,
}),
});
} catch (error) {
throw new SearchAccessTokenError(
`Failed to reach the Sitecore Search token endpoint: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!response.ok) {
throw new SearchAccessTokenError(
`Sitecore Search token endpoint returned ${response.status}`,
);
}
const data = (await response.json()) as {
accessToken?: string;
accessTokenExpiry?: number;
};
if (!data.accessToken) {
throw new SearchAccessTokenError(
"Sitecore Search token endpoint returned no accessToken.",
);
}
return {
accessToken: data.accessToken,
accessTokenExpiry: data.accessTokenExpiry ?? DEFAULT_ACCESS_EXPIRY_MS,
};
}
}
(Yes the comments mention the original date, October 15, 2026 - you can change it or kill the comments.)
Once that's all said and done, you can replace WidgetsProvider from the OOTB Sitecore Search implementation with SearchWidgetsProvider from this one, with no parameters needed to be passed. From there, your search code should work as it always has.
Good luck!
Top comments (0)