Pages

10 April, 2026

Fixing Coveo Analytics InvalidToken and ExpiredToken Errors in Sitecore XM/XP

If you are using Coveo for Sitecore and seeing InvalidToken or ExpiredToken errors in your Coveo analytics logs, this post covers four issues I ran into and how I fixed them. These are specific to Sitecore XP sites using the Coveo REST proxy for analytics, but the patterns may help anyone dealing with Coveo analytics initialization problems.

The Setup

The site had Coveo analytics initialized in multiple places - a shared JavaScript file (coveo-analytics.js) and several cshtml Razor views (CoveoPageViewAnalytics.cshtml, ProductDetails.cshtml, ThankYou.cshtml). Each file loaded the Coveo analytics script using the standard IIFE loader and called coveoua('init', apiKey) independently.

Here is a quick comparison of the two approaches:

Aspect coveo-analytics.js cshtml Razor Views
API Key Source Hardcoded in JS with client-side hostname check Server-side via Settings.GetSetting("CoveoAnalyticsKey", "")
Init Timing Immediate - runs as soon as script loads Deferred - waits for CoveoSearchEndpointInitialized event
Analytics Routing Direct to Coveo cloud Through Sitecore proxy (/coveo/rest/ua/v15)
Sends Events No - just initializes for other components to use Yes - sends page view with metadata

On pages where both the JS file and a cshtml view loaded together, things broke. Here are the four problems and fixes.

Problem 1: Analytics Bypassing Sitecore Proxy

The coveo-analytics.js file was sending analytics events directly to analytics.cloud.coveo.com instead of routing through the Sitecore proxy at /coveo/rest/ua/v15. The cshtml files had the proxy override but the JS file did not.

The fix is to override baseUrl on the Coveo analytics client prototype inside the onLoad callback (you need to wait for the script to load before the prototype is available):

coveoua('onLoad', function () {
    Object.defineProperty(
        coveoanalytics.CoveoAnalyticsClient.prototype,
        'baseUrl',
        { get() { return '/coveo/rest/ua/v15'; } }
    );
    coveoua('init', apiKey);
});

This makes sure all analytics traffic goes through the Sitecore proxy, same as the cshtml views.

Problem 2: InvalidToken from Multiple Initialization

This was the main issue. On pages where both coveo-analytics.js and a cshtml view loaded, coveoua('init') was being called twice. Each call resets the analytics client and creates a new session token. Any events queued from the first initialization become invalid - hence the InvalidToken error.

There was also a secondary issue: the Object.defineProperty call for the proxy override would throw a TypeError on the second call because the property was defined as non-configurable by default.

The fix is a two-flag initialization guard using a shared namespace on window. Why two flags? Because the IIFE script injection is synchronous but the init call happens asynchronously inside onLoad. A single flag would either skip the IIFE or skip the init at the wrong time depending on load order.

window.SITE = window.SITE || {};

// Flag 1: Guard the IIFE script injection (synchronous)
if (!window.SITE._coveoScriptInjected) {
    window.SITE._coveoScriptInjected = true;

    (function (c, o, v, e, O, u, a) {
        a = 'coveoua'; c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) };
        c[a].t = Date.now(); u = o.createElement(v); u.async = 1; u.src = e;
        O = o.getElementsByTagName(v)[0]; O.parentNode.insertBefore(u, O)
    })(window, document, 'script', 'https://static.cloud.coveo.com/coveo.analytics.js/2/coveoua.js');
}

// Flag 2: Guard the init call (asynchronous, inside onLoad)
coveoua('onLoad', function () {
    if (!window.SITE._coveoInitialized) {
        window.SITE._coveoInitialized = true;
        Object.defineProperty(
            coveoanalytics.CoveoAnalyticsClient.prototype,
            'baseUrl',
            { get() { return '/coveo/rest/ua/v15'; } }
        );
        coveoua('init', apiKey);
    }
});

This pattern works regardless of which file loads first - JS or cshtml. The first one to run sets the flag and does the initialization. The rest skip it. Event-sending code like coveoua('send', 'view', metadata) or coveoua('ec:addProduct', ...) does not need guards - those use the internal queue and will execute correctly once init completes.

I applied this guard pattern across all four files that had Coveo analytics initialization.

Problem 3: ExpiredToken and InvalidToken from Bot Traffic

While debugging the above, I also noticed two other errors in the Coveo logs that turned out to be caused by Googlebot crawling the site. This is worth calling out because in today's landscape of bots and AI crawlers, bot behavior is constantly changing. Bots render JavaScript, execute search queries, and trigger analytics events just like real users do. If you are not filtering bot traffic from your analytics endpoints, you end up with polluted data - expired tokens from stale bot renders, invalid sessions from crawlers re-initializing your analytics client, and noise that makes it harder to trust your actual user metrics. Keeping bot traffic out of your analytics pipeline is not optional anymore.

The first was a 419 ExpiredToken on the search proxy (/coveo/rest/search/v2). Sitecore generates a search JWT at page render time with a 24-hour TTL. By the time Googlebot crawled and executed the page, the token was expired. This is a server-side issue - the frontend cannot fix it.

The second was a 400 InvalidToken on the analytics proxy (/coveo/rest/ua/v15). This was a different root cause - the CoveoAnalyticsKey Sitecore setting on one of the sites was misconfigured. It had a search JWT (issued by SearchApi, with a queryExecutor role) instead of a static analytics API key (the xx... format). The analytics endpoint does not accept search tokens, so it returned InvalidToken.

Quick check: If your CoveoAnalyticsKey Sitecore setting starts with xx, you are good. If it looks like a long JWT string, it is wrong - you need the static analytics API key from your Coveo admin console.

Filtering Bot Traffic from Analytics

To stop bots from generating noise in your Coveo analytics, you have a few options:

Approach How Notes
Cloudflare WAF Block Googlebot on /coveo/rest/ua/* only Best option if Cloudflare is in your stack. Do NOT block /coveo/rest/search/ - that will break SEO.
IIS URL Rewrite Match User-Agent containing "Googlebot" on ^coveo/rest/ua/.* and abort Works at the server level before Sitecore processes the request
JavaScript Check navigator.userAgent for bot patterns before calling initCoveoUa() Secondary layer - Googlebot can execute JS, so not fully reliable

The key point: only block bots from the analytics path (/coveo/rest/ua/). The search path (/coveo/rest/search/) needs to stay open for Googlebot to index your search-driven content like product pages and knowledge base articles.

And do not just think about Googlebot. Bingbot, SEMrush, Ahrefs, AI training crawlers - they all behave differently, and they change their patterns over time. None of them have any legitimate reason to send commerce analytics events like add-to-cart or purchase. Filtering them out keeps your Coveo ML models trained on real user behavior, not crawler noise.

Problem 4: Google Translate Users Getting Failed Analytics (CORS Preflight)

This one was not a bot issue at all. I noticed 400 errors on OPTIONS /coveo/rest/ua/v15/analytics/custom in IIS logs, with a real browser User-Agent (iPhone Safari) and a Referer from *.translate.goog.

When a user browses your site through Google Translate, the page is served from *.translate.goog (e.g. yourdomain-com.translate.goog). Any JavaScript on that page making requests back to your actual domain is now cross-origin. The browser sends an HTTP OPTIONS preflight request before the actual analytics POST.

Here is the flow:

  1. User visits yourdomain-com.translate.goog
  2. JS tries to POST analytics to yourdomain.com/coveo/rest/ua/v15/analytics/custom
  3. Browser detects cross-origin and sends OPTIONS preflight first
  4. Sitecore returns 400 (Coveo proxy handler does not handle OPTIONS)
  5. Browser blocks the actual analytics POST
  6. Commerce events (add-to-cart, purchase, page view) are never recorded for translated sessions

These are real users losing analytics tracking - not bots. The Cloudflare bot-blocking rule from Problem 3 should NOT block this traffic.

Why Not Cloudflare?

I initially considered Cloudflare for this fix, but it does not work well here. Cloudflare Transform Rules can add headers but cannot change the HTTP status code. A WAF Custom Rule with "Return fixed response" can return a 204, but it cannot echo back the dynamic Origin header value - which is required for CORS. The origin changes per site (e.g. example-com.translate.goog), so you cannot hardcode it.

Fix: Sitecore httpRequestBegin Pipeline Processor

The fix that worked is a custom Sitecore httpRequestBegin pipeline processor that intercepts OPTIONS preflight requests for the Coveo analytics path and returns 204 with the correct CORS headers before the request reaches the Coveo proxy handler.

public class HandleCoveoCors : HttpRequestProcessor
{
    public override void Process(HttpRequestArgs args)
    {
        var request = HttpContext.Current.Request;
        var response = HttpContext.Current.Response;
        var origin = request.Headers["Origin"] ?? "";

        if (request.HttpMethod == "OPTIONS" &&
            request.Path.StartsWith("/coveo/rest/") &&
            origin.EndsWith(".translate.goog"))
        {
            response.StatusCode = 204;
            response.AddHeader("Access-Control-Allow-Origin", origin);
            response.AddHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
            response.AddHeader("Access-Control-Allow-Headers",
                "Content-Type, Authorization");
            response.AddHeader("Access-Control-Max-Age", "86400");
            response.End();
        }
    }
}

A few notes on the implementation:

  • Why origin.EndsWith(".translate.goog"): Google Translate generates subdomains dynamically per site. Matching the suffix covers all translated variants without hardcoding each one.
  • Why 204 and not 200: 204 No Content is the correct HTTP status for a successful OPTIONS preflight with no response body. Browsers accept both, but 204 is the right semantic choice.
  • Why pipeline processor: It handles both the status code and the dynamic Origin header correctly, and keeps the logic server-side alongside the Coveo proxy handler it is protecting.

Summary

Four issues, four fixes:

  1. Analytics bypassing proxy - Add baseUrl override via Object.defineProperty inside onLoad
  2. InvalidToken from duplicate init - Two-flag initialization guard (_coveoScriptInjected + _coveoInitialized) on a shared window namespace
  3. Bot traffic causing token errors - Block bots from analytics proxy only (not search), and verify your CoveoAnalyticsKey Sitecore setting has a static xx... key
  4. Google Translate CORS preflight - Sitecore httpRequestBegin pipeline processor to handle OPTIONS requests from *.translate.goog with proper CORS headers

Hope this helps if you are dealing with similar Coveo analytics issues in Sitecore. If you have questions, leave a comment below.

No comments:

Post a Comment

blockquote { margin: 0; } blockquote p { padding: 15px; background: #eee; border-radius: 5px; } blockquote p::before { content: '\201C'; } blockquote p::after { content: '\201D'; }