You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/10/21 08:48:40 UTC

(unomi) branch master updated: UNOMI-1005: Allow visitor cookies only for listed website origins (#920)

This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new b4bd3bd7a UNOMI-1005: Allow visitor cookies only for listed website origins (#920)
b4bd3bd7a is described below

commit b4bd3bd7a22ad418a310983c7454b93a80512a5c
Author: Serge Huber <sh...@apache.org>
AuthorDate: Wed Oct 21 10:48:33 2026 +0200

    UNOMI-1005: Allow visitor cookies only for listed website origins (#920)
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../test/java/org/apache/unomi/itests/BaseIT.java  |   3 +
 .../apache/unomi/itests/PublicTrackerCorsIT.java   | 225 +++++++++++++++
 .../itests/SingleTenantCompatibilityModeIT.java    |  47 ++++
 manual/src/main/asciidoc/configuration.adoc        | 144 +++++++++-
 .../main/asciidoc/how-profile-tracking-works.adoc  |  15 +-
 .../main/asciidoc/javascript-tracker-guide.adoc    |  26 +-
 .../asciidoc/migrations/migrate-3.0-to-4.0.adoc    |   6 +-
 .../asciidoc/migrations/v2-compatibility-mode.adoc |   1 +
 manual/src/main/asciidoc/privacy.adoc              |   6 +-
 .../src/main/asciidoc/samples/twitter-sample.adoc  |   2 +-
 manual/src/main/asciidoc/web-tracker.adoc          |   2 +
 manual/src/main/asciidoc/whats-new.adoc            |   8 +
 .../main/resources/etc/custom.system.properties    |  14 +-
 .../unomi/rest/cors/PublicTrackerCorsFilter.java   |  94 +++++++
 .../unomi/rest/cors/PublicTrackerCorsPolicy.java   | 255 +++++++++++++++++
 .../unomi/rest/cors/PublicTrackerSettings.java     | 279 +++++++++++++++++++
 .../unomi/rest/endpoints/ClientEndpoint.java       |   2 +-
 .../org/apache/unomi/rest/server/RestServer.java   |  17 +-
 .../unomi/rest/service/RestServiceUtils.java       |  13 +-
 .../rest/service/impl/RestServiceUtilsImpl.java    | 108 +++++--
 .../java/org/apache/unomi/utils/HttpUtils.java     |  27 --
 .../config/ShippedProfileCookieConfigTest.java     |  11 +
 .../rest/cors/PublicTrackerCorsFilterTest.java     | 195 +++++++++++++
 .../rest/cors/PublicTrackerCorsPolicyTest.java     | 190 +++++++++++++
 .../unomi/rest/cors/PublicTrackerSettingsTest.java | 271 ++++++++++++++++++
 .../unomi/rest/endpoints/ClientEndpointTest.java   |  93 +++++++
 .../RestServiceUtilsImplProfileBindingTest.java    | 309 ++++++++++++++++++++-
 .../unomi/utils/ProfileCookieSameSiteTest.java     |  83 ++++++
 web-servlets/pom.xml                               |  11 +
 .../org/apache/unomi/web/servlets/WebConfig.java   |  31 ++-
 .../src/main/resources/org.apache.unomi.web.cfg    |  12 +-
 .../apache/unomi/web/servlets/WebConfigTest.java   |  55 ++++
 33 files changed, 2467 insertions(+), 89 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 50f160411..10247e2c8 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -59,6 +59,7 @@ import org.junit.runners.Suite.SuiteClasses;
         CopyPropertiesActionIT.class,
         IncrementPropertyIT.class,
         InputValidationIT.class,
+        PublicTrackerCorsIT.class,
         RestCreateValidationIT.class,
         ModifyConsentIT.class,
         PatchIT.class,
diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
index 7f326360a..16e1c5806 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -678,6 +678,9 @@ public abstract class BaseIT extends KarafTestSupport {
                 // Explicit test credentials (package no longer ships a known default password).
                 editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD),
                 editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD),
+                // Engine-independent; requests without an Origin header (all other ITs) are unaffected.
+                editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.public.cors.allowedOrigins", PublicTrackerCorsIT.LISTED_ORIGIN),
+                editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.public.cors.matchReferer", "true"),
                 // Default scheduler.thread.poolSize (5) is sized for the near-instant in-memory unit-test
                 // double, not a real ES/OS backend. Under real refresh/write latency, the checker, task
                 // executions, and lease-renewal heartbeats (see scheduler.adoc) compete for the same small
diff --git a/itests/src/test/java/org/apache/unomi/itests/PublicTrackerCorsIT.java b/itests/src/test/java/org/apache/unomi/itests/PublicTrackerCorsIT.java
new file mode 100644
index 000000000..9b0aef540
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/PublicTrackerCorsIT.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.Header;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpOptions;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Drives the public tracker endpoints through the real filter chain (CXF CORS filter, authentication,
+ * {@code PublicTrackerCorsFilter}). {@link BaseIT} lists {@link #LISTED_ORIGIN} in
+ * {@code org.apache.unomi.public.cors.allowedOrigins} and turns on {@code org.apache.unomi.public.cors.matchReferer}.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class PublicTrackerCorsIT extends BaseIT {
+
+    static final String LISTED_ORIGIN = "https://allowed-site.example";
+    private static final String UNLISTED_ORIGIN = "https://other-site.example";
+    private static final String CONTEXT_JSON = "/cxs/context.json";
+
+    /** A fresh session per test, so no test inherits another's session owner. */
+    private String sessionId;
+
+    @Before
+    public void newSession() {
+        sessionId = "public-tracker-cors-it-" + UUID.randomUUID();
+    }
+
+    @After
+    public void tearDown() {
+        // The suite shares one container: leave no profiles, sessions or events behind.
+        persistenceService.refresh();
+        TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager);
+        TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager);
+        TestUtils.removeAllProfiles(definitionsService, persistenceService, true, tenantService, executionContextManager);
+    }
+
+    @Test
+    public void preflightFromListedOriginAllowsCredentials() throws Exception {
+        try (CloseableHttpResponse response = execute(preflight(CONTEXT_JSON, LISTED_ORIGIN), false)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+            assertEquals(LISTED_ORIGIN, header(response, "Access-Control-Allow-Origin"));
+            assertEquals("true", header(response, "Access-Control-Allow-Credentials"));
+            assertTrue(header(response, "Vary").contains("Origin"));
+            String allowedHeaders = header(response, "Access-Control-Allow-Headers").toLowerCase();
+            assertTrue(allowedHeaders, allowedHeaders.contains("x-unomi-api-key"));
+            assertTrue(allowedHeaders, allowedHeaders.contains("x-unomi-tenant-id"));
+        }
+    }
+
+    @Test
+    public void preflightFromUnlistedOriginAllowsNoCredentials() throws Exception {
+        try (CloseableHttpResponse response = execute(preflight(CONTEXT_JSON, UNLISTED_ORIGIN), false)) {
+            assertEquals("*", header(response, "Access-Control-Allow-Origin"));
+            assertNull(header(response, "Access-Control-Allow-Credentials"));
+        }
+    }
+
+    @Test
+    public void listedOriginKeepsTheVisitorAndGetsCredentials() throws Exception {
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Origin", LISTED_ORIGIN);
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertEquals(LISTED_ORIGIN, header(response, "Access-Control-Allow-Origin"));
+            assertEquals("true", header(response, "Access-Control-Allow-Credentials"));
+            assertEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void unlistedCrossSiteOriginDoesNotUseTheVisitorCookie() throws Exception {
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Origin", UNLISTED_ORIGIN);
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertEquals("*", header(response, "Access-Control-Allow-Origin"));
+            assertNull(header(response, "Access-Control-Allow-Credentials"));
+            assertNull("The visitor's cookie must not be replaced", header(response, "Set-Cookie"));
+            assertNotEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void embeddedCrossSiteRequestWithoutOriginDoesNotUseTheVisitorCookie() throws Exception {
+        // <script src=".../context.js"> on another site: the browser sends no Origin, only Sec-Fetch-Site.
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertNull("The visitor's cookie must not be replaced", header(response, "Set-Cookie"));
+            assertNotEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void listedSiteEmbeddingTheTrackerKeepsTheVisitor() throws Exception {
+        // <script src=".../context.js"> on a listed site: no Origin, but the Referer names the listed page.
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Referer", LISTED_ORIGIN + "/products/42");
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertNotNull(header(response, "Set-Cookie"));
+            assertEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void unlistedSiteEmbeddingTheTrackerDoesNotUseTheVisitorCookie() throws Exception {
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Referer", "https://other-site.example/page");
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertNull("The visitor's cookie must not be replaced", header(response, "Set-Cookie"));
+            assertNotEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void sameOriginPostKeepsTheVisitor() throws Exception {
+        // Browsers send Origin on same-origin POSTs, for example behind a reverse proxy serving /cxs.
+        String[] visitor = newVisitor();
+        HttpPost request = contextRequest();
+        request.setHeader("Origin", BASE_URL + ":" + getHttpPort());
+        request.setHeader("Sec-Fetch-Site", "same-origin");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void contextJsFromListedOriginGetsCredentials() throws Exception {
+        HttpGet request = new HttpGet(getFullUrl("/cxs/context.js?sessionId=" + sessionId));
+        request.setHeader("Origin", LISTED_ORIGIN);
+        try (CloseableHttpResponse response = execute(request, true)) {
+            assertEquals(LISTED_ORIGIN, header(response, "Access-Control-Allow-Origin"));
+            assertEquals("true", header(response, "Access-Control-Allow-Credentials"));
+        }
+    }
+
+    /** Returns the profile id and the cookie of a visitor created without any Origin header. */
+    private String[] newVisitor() throws Exception {
+        try (CloseableHttpResponse response = execute(contextRequest(), true)) {
+            Header setCookie = response.getFirstHeader("Set-Cookie");
+            assertNotNull("A new visitor gets a profile cookie", setCookie);
+            return new String[]{profileId(response), setCookie.getValue().split(";")[0]};
+        }
+    }
+
+    private HttpPost contextRequest() throws Exception {
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_JSON));
+        request.setEntity(new StringEntity("{\"sessionId\":\"" + sessionId + "\"}", ContentType.APPLICATION_JSON));
+        return request;
+    }
+
+    private HttpOptions preflight(String path, String origin) throws Exception {
+        HttpOptions request = new HttpOptions(getFullUrl(path));
+        request.setHeader("Origin", origin);
+        request.setHeader("Access-Control-Request-Method", "POST");
+        request.setHeader("Access-Control-Request-Headers", "content-type,x-unomi-api-key,x-unomi-tenant-id");
+        return request;
+    }
+
+    private static CloseableHttpResponse execute(HttpUriRequest request, boolean withPublicKey) throws IOException {
+        return HttpClientThatWaitsForUnomi.doRequest(request, -1, withPublicKey, false);
+    }
+
+    private String profileId(CloseableHttpResponse response) throws IOException {
+        assertEquals(200, response.getStatusLine().getStatusCode());
+        return getObjectMapper().readTree(EntityUtils.toString(response.getEntity())).get("profileId").asText();
+    }
+
+    private static String header(CloseableHttpResponse response, String name) {
+        Header header = response.getFirstHeader(name);
+        return header == null ? null : header.getValue();
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java
index 7bf63c4d1..fec79f77f 100644
--- a/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java
@@ -653,4 +653,51 @@ public class SingleTenantCompatibilityModeIT extends BaseIT {
         restAuthenticationConfig = getService(RestAuthenticationConfig.class);
         v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class);
     }
+
+    /**
+     * UNOMI-1005: a plain "download my data" link on the website sends no Origin. In this mode the endpoint
+     * needs no API key, so the link must keep the visitor as a top-level navigation, and only as one.
+     */
+    @Test
+    public void testDownloadMyDataLinkKeepsTheVisitor() throws Exception {
+        updateConfiguration(null, "org.apache.unomi.rest.authentication", "singletenantcompatibility.enabled", true);
+        keepTrying("single-tenant compatibility mode not enabled in the required time",
+                () -> restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(),
+                enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+        RequestResponse created = executeContextJSONRequest(request, TEST_SESSION_ID, 200, false);
+        assertNotNull("A new visitor gets a profile cookie", created.getCookieHeaderValue());
+        String cookie = created.getCookieHeaderValue().split(";")[0];
+
+        // The profile is saved asynchronously, so retry until the link finds it.
+        keepTrying("A top-level navigation to the download link should return the visitor's data",
+                () -> downloadStatus(cookie, "navigate", "document"),
+                status -> status == 200, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        assertEquals("A framed download must not use the visitor cookie", 404, downloadStatus(cookie, "navigate", "iframe"));
+        assertEquals("A scripted download must not use the visitor cookie", 404, downloadStatus(cookie, "cors", "empty"));
+    }
+
+    private int downloadStatus(String cookie, String fetchMode, String fetchDest) {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpGet download = new HttpGet(getFullUrl("/cxs/client/myprofile/json"));
+            download.setHeader("Cookie", cookie);
+            download.setHeader("Sec-Fetch-Site", "cross-site");
+            download.setHeader("Sec-Fetch-Mode", fetchMode);
+            download.setHeader("Sec-Fetch-Dest", fetchDest);
+            try (CloseableHttpResponse response = client.execute(download)) {
+                if (response.getStatusLine().getStatusCode() == 200) {
+                    assertTrue("The download is sent as an attachment",
+                            response.getFirstHeader("Content-Disposition").getValue().startsWith("attachment"));
+                }
+                return response.getStatusLine().getStatusCode();
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
 }
diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc
index 3ba40441e..17ed1cf3c 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -467,7 +467,7 @@ curl -X GET "http://localhost:8181/cxs/tenants" \
 
 Authentication rules (Unomi 4.0):
 
-1. Public paths (for example `/cxs/context.json`, `/cxs/eventcollector`, `/cxs/client/*`) require a valid **public** API key via `X-Unomi-Api-Key` (unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled). Browser clients may call them from any origin; those responses do not include credentialed CORS headers.
+1. Public paths (for example `/cxs/context.json`, `/cxs/eventcollector`, `/cxs/client/*`) require a valid **public** API key via `X-Unomi-Api-Key` (unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled). Browser clients may call them from any origin, but only listed origins and Unomi's own origin may use the visitor cookie; see <<_public_tracker_origins,Websites on other origins>>.
 2. Private paths accept `tenantId:privateApiKey` Basic auth, or JAAS admin credentials (often with `X-Unomi-Tenant-Id` when a tenant context is required).
 3. Tenant administration (`/cxs/tenants`) requires system administrator JAAS credentials.
 4. JAAS admin does **not** replace the public API key on public endpoints.
@@ -547,6 +547,148 @@ curl -X POST "http://localhost:8181/cxs/segments" \
   }'
 ----
 
+[[_public_tracker_origins]]
+==== Websites on other origins (visitor cookie and CORS)
+
+The public tracker endpoints (`/cxs/context.js`, `/cxs/context.json`, `/cxs/eventcollector` and `/cxs/client/*`) accept requests from any origin. The visitor cookie (`context-profile-id` by default), which identifies the visitor, is only used and set for:
+
+* pages served from Unomi's own origin;
+* origins the operator lists in `org.apache.unomi.public.cors.allowedOrigins`;
+* requests that carry no sign of coming from a page on another site, such as server-to-server calls.
+
+For any other page, Unomi answers without credentialed CORS headers and ignores the cookie: the request gets a new anonymous profile, and the visitor's cookie is not replaced.
+
+===== Choosing a setup
+
+[cols="2,1,3", options="header"]
+|===
+|Website and Unomi |List needed |Notes
+
+|Same origin: a reverse proxy serves the website and `/cxs` on one host (`+https://www.example.com/cxs/+`)
+|No
+|Recommended. The cookie is a first-party cookie and needs no CORS at all.
+
+|Another subdomain of the same site (`https://www.example.com` and `https://cdp.example.com`)
+|Yes
+|List `https://www.example.com`. `SameSite=Lax` is enough. Set `org.apache.unomi.profile.cookie.domain=example.com` if the cookie must also be visible to other subdomains.
+
+|Another site (`https://www.shop.com` and `https://cdp.example.com`)
+|Yes
+|List `https://www.shop.com`, serve Unomi over HTTPS and set `SameSite=None`. The cookie is then a third-party cookie: it does not work in Safari, which blocks it; Firefox partitions it per top-level site; and Chrome users may block it. Prefer one of the setups above.
+|===
+
+A response can only set the cookie on Unomi's own host, or on a parent domain set with `org.apache.unomi.profile.cookie.domain`. It cannot place a cookie on the website's domain. To get a first-party cookie for the website, serve Unomi from that website's origin or from one of its subdomains.
+
+===== Settings
+
+[source]
+----
+# etc/custom.system.properties (or the environment variables)
+org.apache.unomi.public.cors.allowedOrigins=${env:UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS:-}
+org.apache.unomi.profile.cookie.sameSite=${env:UNOMI_PROFILE_COOKIE_SAMESITE:-Lax}
+org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}
+----
+
+Example:
+
+[source,bash]
+----
+export UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS='https://www.example.com,https://shop.example.com:8443'
+----
+
+Another site only (Unomi served over HTTPS):
+
+[source,bash]
+----
+export UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS='https://www.shop.com'
+export UNOMI_PROFILE_COOKIE_SAMESITE=None
+----
+
+* Entries are comma-separated and give a scheme (`http` or `https`), a host and an optional port. They are compared the way browsers send the `Origin` header: case does not matter, a default port (`:443`, `:80`) may be left out, and a trailing `/` is ignored.
+* Wildcards, paths and `null` are not accepted. A rejected entry is logged as a warning when the setting is applied, and so is a list in which no entry is valid.
+* The list is empty by default: no other origin may use the cookie.
+* A listed origin is named exactly in the response, with `Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight requests included. An unlisted origin gets `Access-Control-Allow-Origin: *` without credentials.
+* `SameSite` accepts `Lax` (default), `Strict` and `None`. `None` is used only when the cookie is `Secure` and the list is not empty; otherwise Unomi uses `Lax` and logs a warning.
+* `matchReferer` (default `false`) lets listed websites that embed the tracker with a `<script>` tag keep their visitors; see <<_public_tracker_script_tag,Embedding `context.js` with a script tag>>. Only `true` turns it on; any other value, and `true` with no origin listed, is logged as a warning.
+* Changes are applied without a restart and are parsed once, not on every request.
+
+Do not:
+
+* list origins you do not control, or every subdomain "just in case": a listed page can act as the visitor;
+* use `*` (it is refused);
+* list the `http://` origin of a website that also runs over HTTPS: list the `https://` one, which is what its pages send;
+* turn on `matchReferer` "to make it work": it trusts everything the listed websites frame (see <<_public_tracker_script_tag,Embedding `context.js` with a script tag>>);
+* set `SameSite=None` for a same-site setup: `Lax` already works there.
+
+===== How Unomi recognises the page
+
+Current browsers send `Origin` on every cross-origin request and on every `POST`, same-origin ones included, so its presence alone does not mean another site. Unomi decides as follows:
+
+. An `Origin` on the list is allowed.
+. Without `Origin`, as for a `<script src=".../context.js">` tag, and only when `matchReferer` is on, the browser-set `Referer` is matched against the list instead.
+. Otherwise the browser's `Sec-Fetch-Site` header decides: only `same-origin` and `none` (a page typed in the address bar) are allowed. It takes precedence over the host comparison below.
+. A request with neither `Origin` nor `Sec-Fetch-Site` is allowed. That is a server-side caller, or a `GET` from a browser that does not send `Sec-Fetch-Site` (Chrome before 76, Firefox before 90, Safari before 16.4), which cannot be told apart from it. In such a browser, another site's `<script>` or `<img>` can therefore use the visitor cookie.
++
+This only matters with `SameSite=None`: with the default `Lax`, a browser does not send the cookie to another site's embeds at all, and Safari blocks third-party cookies whatever their `SameSite`. Current versions of Chrome, Edge, Firefox and Safari all send `Sec-Fetch-Site`.
+
+. For older browsers that send `Origin` but not `Sec-Fetch-Site` (for example Safari before 16.4), a page on the same host as Unomi counts as Unomi's own origin. A proxy that rewrites `Host` must then send `X-Forwarded-Host`.
+
+===== Behind a reverse proxy
+
+The cookie is marked `Secure` when the request reached Unomi over HTTPS. Behind a proxy that terminates TLS, that is only the case when the proxy's address is in `org.apache.unomi.ip.trustedProxies` (the default already covers private networks) and every value it sends in `X-Forwarded-Proto` is `https`. Without it, a configured `SameSite=None` falls back to `Lax`, and a warning is logged once per configuration change.
+
+[[_public_tracker_script_tag]]
+===== Embedding `context.js` with a script tag
+
+A page that loads the tracker with a plain `<script src="https://cdp.example.com/cxs/context.js">` (no `crossorigin` attribute) sends no `Origin` header, only a `Referer`. Public endpoints need an API key header, which a script tag cannot send, so this only applies in <<_v2_compatibility_mode,single-tenant compatibility mode>>, where clients written for 2.x often embed `context.js` this way.
+
+By default, in browsers that send `Sec-Fetch-Site`, such a request from another origin is served without the visitor cookie, even from a listed website: every page view starts a new anonymous profile. By default, once origins are listed, Unomi logs a warning about it once per configuration change. To accept it, set `org.apache.unomi.public.cors.matchReferer=true` (`UNOMI_PUBLIC_CORS_MATCH_REFERER`). A `Referer` naming a listed page is then accepted.
+
+Before turning it on:
+
+* It trusts everything a listed page frames. A sandboxed `<iframe srcdoc>` or `about:blank` frame, such as an ad, reports the page that created it as its `Referer`, so its scripts would receive the visitor's profile. Turn it on only for websites that do not frame untrusted content, or switch them to the shipped tracker, which calls `context.json` with credentials and needs no `Referer`.
+* The listed pages must send their origin in `Referer` to other sites. The browser default (`strict-origin-when-cross-origin`) does. `no-referrer`, `same-origin` or a `referrerpolicy` attribute on the `<script>` tag that removes it makes every page view start a new profile. So does an HTTPS page loading Unomi over HTTP.
+
+[[_public_tracker_compatibility]]
+===== Compatibility with Unomi 2.x and 3.x
+
+In 2.x and 3.x the public endpoints allowed credentialed CORS for every origin, and nothing checked where a request came from. The cookie was always `SameSite=Lax`, so only pages on the same site could use it. A website on another site kept the profile id itself and sent it as a body `profileId`, which 4.0 ignores for public callers.
+
+[cols="2,3", options="header"]
+|===
+|Deployment in 2.x or 3.x |What it needs in 4.0
+
+|Website and Unomi on the same origin
+|Nothing.
+
+|Website on another subdomain using the cookie
+|List the website origin. `SameSite=Lax` is enough.
+
+|Website on another site sending body `profileId`
+|List the website origin, serve Unomi over HTTPS and set `SameSite=None`, or serve Unomi from the website's origin. Expect the cookie to be blocked in Safari.
+
+|2.x client embedding `context.js` with a `<script>` tag (single-tenant compatibility mode)
+|List the website origin and set `matchReferer=true`, after reading <<_public_tracker_script_tag,the trade-offs>>; or move to the shipped tracker. A website on another site also needs HTTPS with `SameSite=None`: in 2.x only same-site embeds kept their profile.
+
+|Server-side caller sending no `Origin`
+|Nothing for the origin check. A body `profileId` needs a trusted caller, as described in the migration guide.
+|===
+
+Other behaviour that changed:
+
+* A refused request receives no `Set-Cookie`, so the visitor's cookie is left untouched.
+* A malformed profile cookie still answers `400`, but only for requests that may use the cookie; a refused request ignores it.
+* The cookie is marked `Secure` behind a trusted TLS proxy, so a website that mixes HTTP and HTTPS pages loses the cookie on its HTTP pages.
+* `org.apache.unomi.profile.cookie.httpOnly` now defaults to `true` (it was `false` in 2.x and 3.x) and is turned off only by `false`. In 4.0.0-rc1 a mistyped value turned `HttpOnly` off; it now keeps it on and is logged as a warning.
+
+===== "Download my data" links
+
+`/cxs/client/myprofile/{format}` returns the visitor's own profile and changes nothing. In <<_v2_compatibility_mode,single-tenant compatibility mode>>, where it needs no API key, a plain link to it on the website is accepted as a top-level navigation (`Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest: document`) even from a page Unomi does not otherwise recognise: the response opens in the visitor's own window, and no other page can read it. A link from another site only carries the cookie wit [...]
+
+===== Tracker without cookies
+
+A deployment that does not need the visitor cookie can set `sendCredentials: false` in the tracker's `wemInitConfig` (see <<_unomi_web_tracker_reference,the web tracker>>). Requests are then sent without cookies and work with the default settings, but each request starts a new anonymous profile.
+
 === Scripting security
 
 ==== Multi-layer scripting filtering system
diff --git a/manual/src/main/asciidoc/how-profile-tracking-works.adoc b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
index 6108bc399..4a470e1ad 100644
--- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
+++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
@@ -820,10 +820,12 @@ Cookies are only set for regular profiles, not for Personas. If a `personaId` is
 * **Cookie Attributes**:
   * `Path=/` - Available for all paths on the domain
   * `Max-Age=31536000` - Valid for 1 year by default (configurable via `org.apache.unomi.profile.cookie.maxAgeInSeconds`)
-  * `SameSite=Lax` - CSRF protection
-  * `Secure` - Set if the request is over HTTPS (configurable)
-  * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`)
-  * `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain`
+  * `SameSite=Lax` - CSRF protection. Configurable via `org.apache.unomi.profile.cookie.sameSite`; `None` is used only with `Secure` and a non-empty list of allowed origins
+  * `Secure` - Set if the request reached Unomi over HTTPS, directly or through a trusted proxy that sends `X-Forwarded-Proto: https`
+  * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`; only an explicit `false` turns it off)
+  * `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain`. Unomi's own host, or a parent domain of it; never the website's domain when the website is on another site
+
+The cookie is used and set only for pages on Unomi's own origin, for origins listed in `org.apache.unomi.public.cors.allowedOrigins`, and for requests that do not come from a page on another site. A request from any other page is served with a new anonymous profile, and the visitor's cookie is left untouched. See <<_public_tracker_origins,Websites on other origins>>.
 
 This cookie allows the browser to automatically send the profile ID on subsequent requests, enabling Apache Unomi to load the existing profile.
 
@@ -1198,7 +1200,10 @@ The following configuration properties control profile tracking behavior:
 
 * `org.apache.unomi.profile.cookie.name` - Cookie name (default: `context-profile-id`)
 * `org.apache.unomi.profile.cookie.maxAgeInSeconds` - Cookie expiration time (default: `31536000` = 1 year)
-* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response.
+* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`; only `false` turns it off, any other value keeps it on and is logged as a warning). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response.
 * `org.apache.unomi.profile.cookie.domain` - Cookie domain (optional)
+* `org.apache.unomi.profile.cookie.sameSite` - `Lax` (default), `Strict` or `None`; `None` needs `Secure` and a non-empty origin list
+* `org.apache.unomi.public.cors.allowedOrigins` - Website origins, other than Unomi's own, that may use the cookie (default: none). See <<_public_tracker_origins,Websites on other origins>>
+* `org.apache.unomi.public.cors.matchReferer` - Recognise a listed website that embeds `context.js` with a `<script>` tag by its `Referer` (default: `false`). See <<_public_tracker_script_tag,Embedding `context.js` with a script tag>>
 
 These can be configured in `etc/custom.system.properties` / `org.apache.unomi.web.cfg`.
diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
index b9dca98ac..4deec1519 100644
--- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc
+++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
@@ -917,23 +917,15 @@ getContextWithConsent: function(options, callback) {
 
 ===== Cross-Domain Tracking
 
-For cross-domain tracking, ensure cookies are set with the correct domain:
+The visitor cookie is set by Unomi, not by page script: it is `HttpOnly` by default and belongs to Unomi's host (or the parent domain set in `org.apache.unomi.profile.cookie.domain`). Page code cannot create or move it to the website's domain, and a body `profileId` is ignored for public callers.
 
-[source,javascript]
-----
-// Enhanced cookie setting with domain support
-setCookie: function(name, value, days, domain) {
-    const expires = new Date();
-    expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
-    
-    let cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
-    if (domain) {
-        cookie += `;domain=${domain}`;
-    }
-    
-    document.cookie = cookie;
-}
-----
+When the website and Unomi are on different origins:
+
+* send requests with credentials (`xhr.withCredentials = true` or `fetch(..., {credentials: 'include'})`);
+* have the operator list the website origin in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`;
+* for a website on another site (another registrable domain), also serve Unomi over HTTPS with `SameSite=None`, knowing that browsers increasingly block such third-party cookies.
+
+Serving Unomi from the website's own origin through a reverse proxy avoids all of this. See <<_public_tracker_origins,Websites on other origins>>.
 
 ===== Performance Optimization
 
@@ -1226,7 +1218,7 @@ Here's a complete, production-ready tracker implementation combining all the con
 5. **Test thoroughly** - Test with different browsers, network conditions, and privacy settings
 6. **Monitor and log** - Log errors and track success rates
 7. **Keep session IDs secure** - Use cryptographically secure UUID generation (see UUID generation section below)
-8. **Handle cookie restrictions** - Some browsers block third-party cookies
+8. **Handle cookie restrictions** - Some browsers block third-party cookies; prefer serving Unomi from the website's origin (see <<_public_tracker_origins,Websites on other origins>>)
 
 ==== UUID Generation Security
 
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
index 31e3ba86b..95de931c5 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
@@ -442,6 +442,10 @@ In addition to tenant API keys, Unomi 4.0 hardens several contracts that can bre
 |Sending only `sessionId` could switch onto that session's profile
 |Public callers switch only if the cookie already owns the session; otherwise the session is detached for the request.
 
+|Tracker on another origin
+|Public endpoints answered any origin with credentialed CORS and never checked where a request came from. A page on another subdomain could use the visitor cookie. A website on another site never received it (the cookie was always `SameSite=Lax`), so it kept the profile id itself and sent it as body `profileId`.
+|Only origins listed in `org.apache.unomi.public.cors.allowedOrigins` (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`) get credentialed CORS and have the cookie used and set; other pages get a new anonymous profile and their cookie is left untouched. The default is empty. A website served from Unomi's own origin through a reverse proxy needs no change. One on another subdomain must be listed. One on another site must be listed and, since body `profileId` is now ignored for public callers, also need [...]
+
 |Profile cookie `HttpOnly`
 |Often defaulted to `false` (JS could read `document.cookie`)
 |Default **`true`**. Prefer `profileId` from the JSON response. Opt out with `org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the tradeoff.
@@ -494,7 +498,7 @@ console (port 8102) is not covered by that check.
 
 ===== Migration checklist for client applications
 
-* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); stop treating body `profileId` as authoritative for public calls.
+* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); list every website origin that calls Unomi from another origin in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` (and, for another site, use HTTPS with `SameSite=None`, or serve Unomi from the website's origin); stop treating body `profileId` as authoritative for public calls.
 * Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile.
 * Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone.
 * Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`.
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index ea53c442a..52f551d42 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -33,6 +33,7 @@ When single-tenant compatibility mode is enabled:
   about 30 seconds, so public requests do not wait on each other or re-read it from the database
   every time. A change made on another node is picked up after about that time.
 - **No authentication** is required for non-protected events (like V2)
+- **The visitor cookie is limited to listed websites** (unlike V2). A website on another origin, another subdomain included, must be listed in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` for its requests to use and receive the cookie, whether it calls `context.json` or embeds `context.js` with a `<script>` tag. A plain `<script>` tag (without a `crossorigin` attribute) sends no `Origin`, so the listed website is then recognised by its `Referer`, which must be turned on with `org.apache.unomi.pub [...]
 
 This allows V2 clients to work with Unomi V3 immediately after migration, giving you time to gradually update client applications to use the new V3 authentication model.
 
diff --git a/manual/src/main/asciidoc/privacy.adoc b/manual/src/main/asciidoc/privacy.adoc
index 1a0d8b5bf..e929ec57c 100644
--- a/manual/src/main/asciidoc/privacy.adoc
+++ b/manual/src/main/asciidoc/privacy.adoc
@@ -59,12 +59,14 @@ IDs.
 
 [source]
 ----
-curl -X GET http://localhost:8181/cxs/client/myprofile.[json,csv,yaml,text] \
+curl -X GET http://localhost:8181/cxs/client/myprofile/json \
 -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
 --cookie "context-profile-id=PROFILE-ID"
 ----
 
-where `PROFILE-ID` is the profile identifier for which to download the profile, and `YOUR_PUBLIC_API_KEY` is the tenant public API key (required on Unomi 4.0 public `/cxs/client/*` endpoints unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled).
+where the last path segment is the format (`json`, `csv`, `yaml` or `text`), `PROFILE-ID` is the profile identifier for which to download the profile, and `YOUR_PUBLIC_API_KEY` is the tenant public API key (required on Unomi 4.0 public `/cxs/client/*` endpoints unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled).
+
+In V2 compatibility mode a plain link on the website to this endpoint also works, as a top-level navigation; see <<_public_tracker_origins,Websites on other origins>>.
 
 === Deleting a profile
 
diff --git a/manual/src/main/asciidoc/samples/twitter-sample.adoc b/manual/src/main/asciidoc/samples/twitter-sample.adoc
index 85ac005b8..54bf34f75 100644
--- a/manual/src/main/asciidoc/samples/twitter-sample.adoc
+++ b/manual/src/main/asciidoc/samples/twitter-sample.adoc
@@ -165,7 +165,7 @@ function contextRequest(successCallback, errorCallback, payload) {
 There are a couple of things to note here:
 
 * If we specify a payload, it is expected to use the JSON format so we `stringify` it and encode it if passed as a URL parameter in a `GET` request.
-* We need to make a https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS[`CORS`] request since the Unomi server is most likely not running on the same host than the one from which the request originates. The specific details are fairly standard and we will not explain them here.
+* We need to make a https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS[`CORS`] request since the Unomi server is most likely not running on the same host than the one from which the request originates. The specific details are fairly standard and we will not explain them here. In Unomi 4.0 the page's origin must be listed in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` for the request to carry the visitor cookie (see <<_public_tracker_origins,Websites on other origins>>).
 * We need to either retrieve (from the initial context we retrieved previously using `cxs.sessionId`) or generate a session identifier for our request since Unomi currently requires one.
 * We're calling the `ContextServlet` using the default install URI, specifying the session identifier: `http://localhost:8181/cxs/context.json?sessionId=sessionId`. This URI requests context from Unomi, resulting in an updated `cxs` object in the javascript global scope. The context server can reply to this request either by returning a JSON-only object containing solely the context information as is the case when the requested URI is `context.json`. However, if the client requests `cont [...]
 * We don't need to provide any authentication at all to interact with this part of Unomi since we only have access to read-only data (as well as providing events as we shall see later on). If we had been using the REST API, we would have needed to provide authentication information as well.
diff --git a/manual/src/main/asciidoc/web-tracker.adoc b/manual/src/main/asciidoc/web-tracker.adoc
index 807dbe7d5..cd5341099 100644
--- a/manual/src/main/asciidoc/web-tracker.adoc
+++ b/manual/src/main/asciidoc/web-tracker.adoc
@@ -213,6 +213,8 @@ It is possible to change the name of these cookie by passing the following prope
 
 Please note however that the `contextServerCookieName` will also have to be changed in the server configuration in order for it to work. See the <<_configuration,Configuration>> section for details on how to do this.
 
+The tracker sends requests with cookies. When the website and Unomi are on different origins, another subdomain included (`www.example.com` and `cdp.example.com`), list the website origin on the server (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`); otherwise the browser blocks the responses and Unomi does not use the visitor cookie. A website served from the same origin as Unomi, through a reverse proxy, needs no list. A website on another site also needs HTTPS and `SameSite=None`; see <<_public [...]
+
 For session tracking, it is important to provide a value for the cookie otherwise the tracker will not initialize (a message is displayed in the console that explains that the session cookie is missing). Here is the code excerpt from the initialization code used in the tutorial that creates the initial cookie value.
 
 [source,javascript]
diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc
index 1b54fdbd1..edbea6213 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -32,6 +32,14 @@ Unomi 4.0 requires explicit admin and health-check passwords at startup, treats
 * Profile tracking contract: <<_how_profile_tracking_works,How profile tracking works>>
 * Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]
 
+==== Visitor cookie across websites
+
+The public tracker endpoints answer any origin, but only the operator's list of website origins (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`) and Unomi's own origin may use the visitor cookie. The list is empty by default. `SameSite` is configurable (`UNOMI_PROFILE_COOKIE_SAMESITE`), and `None` requires HTTPS and a non-empty list. Websites on another subdomain that used the cookie with Unomi 2.x or 3.x must be listed; websites on another site must also use HTTPS with `SameSite=None`, or serve Un [...]
+
+* Setup guide: <<_public_tracker_origins,Websites on other origins>>
+* Upgrading from 2.x or 3.x: <<_public_tracker_compatibility,compatibility table>>
+* Jira: https://issues.apache.org/jira/browse/UNOMI-1005[UNOMI-1005]
+
 ==== Cluster-aware task scheduler
 
 Built-in background job scheduler with persistence, cluster locks, recovery, REST API (`/cxs/tasks`), and Karaf shell commands.
diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties
index eb1aeed2d..83eef22fe 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -319,8 +319,20 @@ org.apache.unomi.profile.cookie.name=${env:UNOMI_PROFILE_COOKIE_NAME:-context-pr
 # This setting controls the maximum age of the profile cookie. By default it is set to a year.
 org.apache.unomi.profile.cookie.maxAgeInSeconds=${env:UNOMI_PROFILE_COOKIE_MAXAGEINSECONDS:-31536000}
 # This setting controls if the cookie should be flagged as HttpOnly or not.
-# Default true so browser JavaScript cannot read the profile bearer cookie.
+# Default true so browser JavaScript cannot read the profile bearer cookie. Only false turns it off; any other
+# value keeps it on and is logged as a warning.
 org.apache.unomi.profile.cookie.httpOnly=${env:UNOMI_PROFILE_COOKIE_HTTPONLY:-true}
+# SameSite for the visitor cookie. Keep Lax unless the sites really are different and the cookie is Secure.
+# None is refused unless Secure is also set and UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS is not empty.
+org.apache.unomi.profile.cookie.sameSite=${env:UNOMI_PROFILE_COOKIE_SAMESITE:-Lax}
+# Website origins that may send the visitor cookie to the public tracker endpoints.
+# Example: https://www.example.com,https://shop.example.com:8443
+# Case and default ports (:443, :80) do not matter. Pages served from Unomi's own origin need no entry.
+# Leave empty (the default) so no other origin, other subdomains included, may send cookies. Do not use * .
+org.apache.unomi.public.cors.allowedOrigins=${env:UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS:-}
+# Recognise a listed website that loads context.js with a <script> tag (no Origin header) by its Referer.
+# Keep false unless you need that: it also trusts anything the listed website frames, such as sandboxed ads.
+org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}
 #Allowed profile download formats, actually only csv (horizontal and vertical), json, text and yaml are allowed.
 org.apache.unomi.profile.download.formats=${env:UNOMI_PROFILE_DOWNLOAD_FORMATS:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking that the requests do not exceed the limit.
diff --git a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
new file mode 100644
index 000000000..5dcddfe3b
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.core.MultivaluedMap;
+import java.util.List;
+
+/**
+ * CXF's CORS filter, with cookies allowed on public tracker responses only for origins the operator listed.
+ * <p>
+ * CXF answers preflights and grants the public tracker endpoints to any origin without credentials. This
+ * subclass rewrites that grant right after CXF writes it, in the same response filter call, so the result
+ * does not depend on filter ordering. That matters because CXF 3.x only reads {@code javax.annotation.Priority}:
+ * a separate filter annotated with {@code jakarta.annotation.Priority} gets the default priority and runs
+ * before CXF's, which then overwrites it. Request handling, {@code @PreMatching} and the priority are CXF's own.
+ * A response CXF did not grant is left alone.
+ */
+public class PublicTrackerCorsFilter extends CrossOriginResourceSharingFilter {
+
+    static final String ALLOW_ORIGIN = "Access-Control-Allow-Origin";
+    static final String ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials";
+
+    private final PublicTrackerSettings settings;
+
+    /**
+     * @param settings the resolved list of website origins that may use the visitor cookie
+     */
+    public PublicTrackerCorsFilter(PublicTrackerSettings settings) {
+        this.settings = settings;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
+        super.filter(requestContext, responseContext);
+        restrictCredentials(requestContext, responseContext);
+    }
+
+    void restrictCredentials(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
+        if (!isPublic(requestContext)) {
+            return;
+        }
+        String origin = requestContext.getHeaderString("Origin");
+        MultivaluedMap<String, Object> headers = responseContext.getHeaders();
+        if (origin == null || !headers.containsKey(ALLOW_ORIGIN)) {
+            return;
+        }
+        if (settings.policy().allows(origin)) {
+            headers.putSingle(ALLOW_ORIGIN, origin);
+            headers.putSingle(ALLOW_CREDENTIALS, "true");
+            if (!variesOnOrigin(headers)) {
+                headers.add("Vary", "Origin");
+            }
+            return;
+        }
+        // Never echo an unlisted origin and never advertise cookies to it.
+        headers.putSingle(ALLOW_ORIGIN, "*");
+        headers.remove(ALLOW_CREDENTIALS);
+    }
+
+    private static boolean variesOnOrigin(MultivaluedMap<String, Object> headers) {
+        List<Object> vary = headers.get("Vary");
+        if (vary != null) {
+            for (Object value : vary) {
+                if (String.valueOf(value).contains("Origin")) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean isPublic(ContainerRequestContext requestContext) {
+        return requestContext.getUriInfo() != null
+                && PublicTrackerCorsPolicy.isPublicTrackerPath(requestContext.getUriInfo().getPath());
+    }
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
new file mode 100644
index 000000000..da1d364d3
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Operator allow-list of website origins that may send the visitor cookie to public tracker endpoints.
+ * Origins are compared on scheme, host and port, after lowercasing and dropping a default port, the way
+ * browsers serialize the {@code Origin} header. Wildcards and the {@code null} origin are never allowed.
+ * An empty list is the secure default: no cross-origin cookies. When enabled, a request that carries no
+ * {@code Origin}, such as a {@code <script>} tag, is matched by the listed page in its {@code Referer}.
+ */
+public final class PublicTrackerCorsPolicy {
+
+    private static final Pattern ORIGIN = Pattern.compile(
+            "^(https?)://([a-z0-9.-]+|\\[[0-9a-f:.]+\\])(?::([0-9]{1,5}))?$");
+
+    private static final String HTTPS = "https://";
+    private static final String HTTP = "http://";
+
+    private final Set<String> allowedOrigins;
+    private final boolean matchReferer;
+
+    private PublicTrackerCorsPolicy(Set<String> allowedOrigins, boolean matchReferer) {
+        this.allowedOrigins = allowedOrigins;
+        this.matchReferer = matchReferer;
+    }
+
+    /** For tests: Referer matching off, dropped entries not reported. */
+    static PublicTrackerCorsPolicy parse(String configured) {
+        return parse(configured, false, new ArrayList<>());
+    }
+
+    /**
+     * Parses the comma-separated list. Call it when the setting changes, not per request
+     * (see {@link PublicTrackerSettings}).
+     *
+     * @param matchReferer whether a request without {@code Origin} may be matched by its {@code Referer}
+     * @param warnings     receives a message for each entry dropped
+     */
+    static PublicTrackerCorsPolicy parse(String configured, boolean matchReferer, List<String> warnings) {
+        if (configured == null || configured.isBlank()) {
+            return new PublicTrackerCorsPolicy(Collections.emptySet(), matchReferer);
+        }
+        Set<String> origins = new LinkedHashSet<>();
+        for (String part : configured.split(",")) {
+            String entry = part.trim();
+            if (entry.isEmpty()) {
+                continue;
+            }
+            // An origin has no path, but a trailing slash is an easy mistake to make in the list.
+            String origin = normalize(entry.endsWith("/") ? entry.substring(0, entry.length() - 1) : entry);
+            if (origin == null) {
+                warnings.add("Ignoring public CORS allowed origin '" + entry + "': expected scheme://host[:port] "
+                        + "with http or https, no path and no wildcard");
+                continue;
+            }
+            origins.add(origin);
+        }
+        return new PublicTrackerCorsPolicy(Collections.unmodifiableSet(origins), matchReferer);
+    }
+
+    /**
+     * Returns the origin as a browser serializes it (lowercase, no default port), or {@code null} when it is
+     * not an {@code http} or {@code https} origin with a host and an optional port only.
+     */
+    static String normalize(String origin) {
+        if (origin == null) {
+            return null;
+        }
+        Matcher matcher = ORIGIN.matcher(origin.toLowerCase(Locale.ROOT));
+        if (!matcher.matches()) {
+            return null;
+        }
+        String scheme = matcher.group(1);
+        String host = matcher.group(2);
+        String port = matcher.group(3);
+        boolean defaultPort = port != null
+                && (("http".equals(scheme) && "80".equals(port)) || ("https".equals(scheme) && "443".equals(port)));
+        return scheme + "://" + host + (port == null || defaultPort ? "" : ":" + port);
+    }
+
+    boolean isEmpty() {
+        return allowedOrigins.isEmpty();
+    }
+
+    /**
+     * Exact lookup: the list is stored the way browsers serialize {@code Origin} (lowercase, no default
+     * port), so a browser's header matches as is. Anything else is not a browser origin and is refused.
+     */
+    boolean allows(String origin) {
+        return origin != null && allowedOrigins.contains(origin);
+    }
+
+    /**
+     * Whether a request may be bound to, or given, the visitor cookie. That is the case for a listed origin,
+     * for Unomi's own origin, and for a request that shows no sign of coming from another site's page.
+     * Current browsers send {@code Origin} on every POST, same-origin ones included, so its presence alone
+     * is not a reason to refuse. Runs on every tracker request: no parsing or regex, only string comparisons
+     * (and a scan of the short list for the {@code Referer} match).
+     *
+     * @param origin        the {@code Origin} header, may be {@code null}
+     * @param referer       the {@code Referer} header, may be {@code null}; matched against the list only when
+     *                      that is enabled and the browser sends no {@code Origin}, as for {@code <script src>}
+     * @param secFetchSite  the {@code Sec-Fetch-Site} header, may be {@code null} for older browsers
+     * @param host          the host the request was addressed to, may be {@code null}
+     * @param forwardedHost the {@code X-Forwarded-Host} header, may be {@code null}
+     * @return whether the request may use, and be given, the visitor cookie
+     */
+    public boolean allowsVisitorCookie(String origin, String referer, String secFetchSite, String host, String forwardedHost) {
+        if (origin != null ? allowedOrigins.contains(origin) : matchReferer && isFromListedPage(referer)) {
+            return true;
+        }
+        if (secFetchSite != null) {
+            // Set by the browser, lowercase, and not writable by page script. It is also sent where Origin
+            // is not, such as a <script> or <img> embedded by another site, so it is checked first.
+            return "same-origin".equals(secFetchSite) || "none".equals(secFetchSite);
+        }
+        if (origin == null) {
+            // No browser marker at all: a server-side caller, or a GET from an older browser that sends no
+            // Sec-Fetch-Site, which cannot be told apart from it.
+            return true;
+        }
+        // Older browsers: the same host counts as the page being served by Unomi itself. The scheme and
+        // port are not compared, since a TLS-terminating proxy changes both on the way in.
+        int hostStart;
+        if (origin.startsWith(HTTPS)) {
+            hostStart = HTTPS.length();
+        } else if (origin.startsWith(HTTP)) {
+            hostStart = HTTP.length();
+        } else {
+            return false;
+        }
+        int hostEnd = hostEnd(origin, hostStart);
+        return sameHost(origin, hostStart, hostEnd, host) || sameHost(origin, hostStart, hostEnd, forwardedHost);
+    }
+
+    boolean matchesReferer() {
+        return matchReferer;
+    }
+
+    /**
+     * Whether a {@code Referer} URL belongs to a listed origin. Browsers set it, lowercase the scheme and
+     * host, drop default ports, and let a page shorten it but never name another origin. A missing one
+     * (for example {@code Referrer-Policy: no-referrer}) is simply not a match. A document without an origin
+     * of its own, such as a sandboxed {@code srcdoc} frame, reports the page that created it, which is why
+     * this match is opt-in: it trusts everything a listed page frames.
+     */
+    private boolean isFromListedPage(String referer) {
+        if (referer == null) {
+            return false;
+        }
+        for (String allowed : allowedOrigins) {
+            int end = allowed.length();
+            if (referer.startsWith(allowed) && (referer.length() == end || referer.charAt(end) == '/')) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** End of the host in {@code value} starting at {@code start}: before the port, a comma or the end. */
+    private static int hostEnd(String value, int start) {
+        int end = value.length();
+        int search = start;
+        if (start < end && value.charAt(start) == '[') {
+            int bracket = value.indexOf(']', start);
+            if (bracket < 0) {
+                return end;
+            }
+            search = bracket;
+        }
+        for (int i = search; i < end; i++) {
+            char c = value.charAt(i);
+            if (c == ':' || c == ',' || c == '/') {
+                return i;
+            }
+        }
+        return end;
+    }
+
+    private static boolean sameHost(String origin, int originStart, int originEnd, String hostHeader) {
+        if (hostHeader == null) {
+            return false;
+        }
+        int start = 0;
+        while (start < hostHeader.length() && hostHeader.charAt(start) == ' ') {
+            start++;
+        }
+        int length = originEnd - originStart;
+        return length > 0
+                && hostEnd(hostHeader, start) - start == length
+                && hostHeader.regionMatches(true, start, origin, originStart, length);
+    }
+
+    /**
+     * {@code SameSite=None} is only valid together with {@code Secure} and a non-empty origin list.
+     * Anything else falls back to {@code Lax}.
+     */
+    String resolveSameSite(String requested, boolean secure) {
+        if (requested == null || requested.isBlank()) {
+            return "Lax";
+        }
+        String value = requested.trim();
+        if ("None".equalsIgnoreCase(value)) {
+            if (secure && !allowedOrigins.isEmpty()) {
+                return "None";
+            }
+            return "Lax";
+        }
+        if ("Strict".equalsIgnoreCase(value)) {
+            return "Strict";
+        }
+        return "Lax";
+    }
+
+    /** The public tracker endpoints, relative to the REST base address. Runs on every response. */
+    static boolean isPublicTrackerPath(String path) {
+        if (path == null) {
+            return false;
+        }
+        int start = path.startsWith("/") ? 1 : 0;
+        return isSegment(path, start, "context.js")
+                || isSegment(path, start, "context.json")
+                || isSegment(path, start, "eventcollector")
+                || isSegment(path, start, "client");
+    }
+
+    private static boolean isSegment(String path, int start, String segment) {
+        int end = start + segment.length();
+        return path.startsWith(segment, start) && (path.length() == end || path.charAt(end) == '/');
+    }
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java
new file mode 100644
index 000000000..92e47a846
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java
@@ -0,0 +1,279 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeEvent;
+import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeListener;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * The public tracker origin list and profile cookie settings, parsed and resolved when they are set
+ * rather than on every request. The web configuration publishes them through {@link ConfigSharingService};
+ * this component reads them at activation and again whenever one of them changes. Requests only read
+ * the results.
+ */
+@Component(service = {PublicTrackerSettings.class, ConfigChangeListener.class})
+public class PublicTrackerSettings implements ConfigChangeListener {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(PublicTrackerSettings.class);
+
+    // Published by WebConfig through ConfigSharingService.
+    static final String ALLOWED_ORIGINS_PROPERTY = "publicCorsAllowedOrigins";
+    static final String SAME_SITE_PROPERTY = "profileIdCookieSameSite";
+    static final String MATCH_REFERER_PROPERTY = "publicCorsMatchReferer";
+    static final String COOKIE_NAME_PROPERTY = "profileIdCookieName";
+    static final String COOKIE_DOMAIN_PROPERTY = "profileIdCookieDomain";
+    static final String COOKIE_MAX_AGE_PROPERTY = "profileIdCookieMaxAgeInSeconds";
+    static final String COOKIE_HTTP_ONLY_PROPERTY = "profileIdCookieHttpOnly";
+
+    static final String DEFAULT_COOKIE_NAME = "context-profile-id";
+    static final int DEFAULT_COOKIE_MAX_AGE = 31536000;
+
+    private static final Set<String> WATCHED = Set.of(
+            ALLOWED_ORIGINS_PROPERTY, SAME_SITE_PROPERTY, MATCH_REFERER_PROPERTY,
+            COOKIE_NAME_PROPERTY, COOKIE_DOMAIN_PROPERTY, COOKIE_MAX_AGE_PROPERTY, COOKIE_HTTP_ONLY_PROPERTY);
+
+    /** Everything a request needs, replaced as a whole so a request never sees half of a change. */
+    private static final class Resolved {
+        final PublicTrackerCorsPolicy policy;
+        final String sameSiteWhenSecure;
+        final String cookieName;
+        /** {@code name=}, then the attributes after the value, for secure and plain HTTP requests. */
+        final String cookiePrefix;
+        final String cookieAttributesWhenSecure;
+        final String cookieAttributesWhenInsecure;
+        /** Set while a refusal of SameSite=None, only because a request was not over HTTPS, is unreported for this configuration. */
+        final AtomicBoolean insecureRefusalToReport;
+        /** Set until a refused request without Origin, typically a script tag, is reported for this configuration. */
+        final AtomicBoolean refusedEmbedToReport;
+        /** Problems found in this configuration, logged when it is applied. */
+        final List<String> warnings;
+
+        Resolved(PublicTrackerCorsPolicy policy, String requestedSameSite, String cookieName, String cookieDomain,
+                 int cookieMaxAge, boolean cookieHttpOnly, List<String> warnings) {
+            this.policy = policy;
+            this.warnings = List.copyOf(warnings);
+            this.sameSiteWhenSecure = policy.resolveSameSite(requestedSameSite, true);
+            String sameSiteWhenInsecure = policy.resolveSameSite(requestedSameSite, false);
+            this.cookieName = cookieName;
+            this.cookiePrefix = cookieName + "=";
+            String common = "; Path=/; Max-Age=" + cookieMaxAge
+                    + (cookieDomain == null || cookieDomain.isBlank() ? "" : "; Domain=" + cookieDomain.trim());
+            String httpOnly = cookieHttpOnly ? "; HttpOnly" : "";
+            this.cookieAttributesWhenSecure = common + "; SameSite=" + sameSiteWhenSecure + "; Secure" + httpOnly;
+            this.cookieAttributesWhenInsecure = common + "; SameSite=" + sameSiteWhenInsecure + httpOnly;
+            this.insecureRefusalToReport = new AtomicBoolean("None".equals(sameSiteWhenSecure) && !"None".equals(sameSiteWhenInsecure));
+            // Only worth reporting once the operator has listed websites.
+            this.refusedEmbedToReport = new AtomicBoolean(!policy.isEmpty());
+        }
+    }
+
+    private volatile Resolved resolved = resolve(null, null, null, null, null, null, null);
+
+    @Reference
+    private ConfigSharingService configSharingService;
+
+    /** Used by OSGi Declarative Services; the settings are read at activation. */
+    public PublicTrackerSettings() {
+    }
+
+    /** For tests; cookie name, domain, max age and HttpOnly take their defaults. */
+    public PublicTrackerSettings(String allowedOrigins, String sameSite) {
+        this(allowedOrigins, sameSite, false);
+    }
+
+    /** For tests; cookie name, domain, max age and HttpOnly take their defaults. */
+    public PublicTrackerSettings(String allowedOrigins, String sameSite, boolean matchReferer) {
+        this.resolved = resolve(allowedOrigins, sameSite, matchReferer, null, null, null, null);
+    }
+
+    @Activate
+    void activate() {
+        reload();
+    }
+
+    @Override
+    public void configChanged(ConfigChangeEvent event) {
+        if (WATCHED.contains(event.getName())) {
+            reload();
+        }
+    }
+
+    // Synchronized so that two reloads (activation and a configuration event) cannot interleave.
+    private synchronized void reload() {
+        Resolved previous = resolved;
+        Resolved next = resolve(
+                configSharingService.getProperty(ALLOWED_ORIGINS_PROPERTY),
+                configSharingService.getProperty(SAME_SITE_PROPERTY),
+                configSharingService.getProperty(MATCH_REFERER_PROPERTY),
+                configSharingService.getProperty(COOKIE_NAME_PROPERTY),
+                configSharingService.getProperty(COOKIE_DOMAIN_PROPERTY),
+                configSharingService.getProperty(COOKIE_MAX_AGE_PROPERTY),
+                configSharingService.getProperty(COOKIE_HTTP_ONLY_PROPERTY));
+        for (String warning : newWarnings(previous.warnings, next.warnings)) {
+            LOGGER.warn(warning);
+        }
+        this.resolved = next;
+    }
+
+    /**
+     * The warnings of a new configuration that the previous one did not already have. WebConfig publishes the
+     * settings one by one, each causing a reload, so a problem would otherwise be logged once per setting.
+     */
+    static List<String> newWarnings(List<String> previous, List<String> next) {
+        List<String> added = new ArrayList<>();
+        for (String warning : next) {
+            if (!previous.contains(warning)) {
+                added.add(warning);
+            }
+        }
+        return added;
+    }
+
+    /** The problems found in the current configuration, as they were logged. */
+    List<String> configurationWarnings() {
+        return resolved.warnings;
+    }
+
+    private static Resolved resolve(Object allowedOrigins, Object sameSite, Object matchReferer, Object cookieName,
+                                    Object cookieDomain, Object cookieMaxAge, Object cookieHttpOnly) {
+        List<String> warnings = new ArrayList<>();
+        String requested = sameSite == null ? null : sameSite.toString().trim();
+        String name = cookieName == null || cookieName.toString().isBlank() ? DEFAULT_COOKIE_NAME : cookieName.toString().trim();
+        String origins = allowedOrigins == null ? null : allowedOrigins.toString();
+        // Off unless explicitly turned on: it trusts everything a listed page frames.
+        String refererSetting = matchReferer == null ? "" : matchReferer.toString().trim();
+        boolean refererMatch = "true".equalsIgnoreCase(refererSetting);
+        if (!refererSetting.isEmpty() && !refererMatch && !"false".equalsIgnoreCase(refererSetting)) {
+            warnings.add("Unrecognised value '" + refererSetting + "' for org.apache.unomi.public.cors.matchReferer; "
+                    + "only true turns it on, so Referer matching is off.");
+        }
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(origins, refererMatch, warnings);
+        if (origins != null && !origins.isBlank() && policy.isEmpty()) {
+            warnings.add("None of the public CORS allowed origins '" + origins + "' is valid, so no other website may "
+                    + "use the visitor cookie. Expected scheme://host[:port], comma-separated.");
+        }
+        if (refererMatch && policy.isEmpty()) {
+            warnings.add("org.apache.unomi.public.cors.matchReferer is on but no website origin is listed, so it has no effect.");
+        }
+        if ("None".equalsIgnoreCase(requested) && !"None".equals(policy.resolveSameSite(requested, true))) {
+            warnings.add("Refusing SameSite=None for the profile cookie because the public CORS allowed origins list "
+                    + "has no valid entry; using SameSite=Lax.");
+        }
+        int maxAge = toInt(cookieMaxAge, warnings);
+        String httpOnlySetting = cookieHttpOnly == null ? "" : cookieHttpOnly.toString().trim();
+        if (!httpOnlySetting.isEmpty() && !"true".equalsIgnoreCase(httpOnlySetting) && !"false".equalsIgnoreCase(httpOnlySetting)) {
+            warnings.add("Unrecognised value '" + httpOnlySetting + "' for org.apache.unomi.profile.cookie.httpOnly; "
+                    + "only false turns it off, so the cookie stays HttpOnly.");
+        }
+        return new Resolved(
+                policy,
+                requested,
+                name,
+                cookieDomain == null ? null : cookieDomain.toString(),
+                maxAge,
+                // HttpOnly unless explicitly turned off: page script must not read the bearer cookie,
+                // so a typo keeps it on.
+                cookieHttpOnly == null || !"false".equalsIgnoreCase(cookieHttpOnly.toString().trim()),
+                warnings);
+    }
+
+    private static int toInt(Object value, List<String> warnings) {
+        if (value == null) {
+            return DEFAULT_COOKIE_MAX_AGE;
+        }
+        try {
+            int maxAge = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString().trim());
+            if (maxAge > 0) {
+                return maxAge;
+            }
+        } catch (NumberFormatException e) {
+            // reported below
+        }
+        // Zero or less would make browsers delete the cookie on every response.
+        warnings.add("Invalid profile cookie max age '" + value + "'; using " + DEFAULT_COOKIE_MAX_AGE);
+        return DEFAULT_COOKIE_MAX_AGE;
+    }
+
+    /** @return the website origins that may use the visitor cookie, as currently configured */
+    public PublicTrackerCorsPolicy policy() {
+        return resolved.policy;
+    }
+
+    /** @return the name of the visitor cookie, as currently configured */
+    public String cookieName() {
+        return resolved.cookieName;
+    }
+
+    /** The {@code Set-Cookie} value naming this profile; only the profile id is added per request. */
+    public String profileCookie(String profileId, boolean secure) {
+        Resolved current = resolved;
+        if (!secure) {
+            reportInsecureRefusal(current);
+        }
+        return current.cookiePrefix + profileId
+                + (secure ? current.cookieAttributesWhenSecure : current.cookieAttributesWhenInsecure);
+    }
+
+    private static void reportInsecureRefusal(Resolved current) {
+        if (current.insecureRefusalToReport.get() && current.insecureRefusalToReport.compareAndSet(true, false)) {
+            LOGGER.warn("Refusing SameSite=None for the profile cookie because the request did not arrive over HTTPS; "
+                    + "using SameSite=Lax. Behind a TLS-terminating proxy, list it in org.apache.unomi.ip.trustedProxies "
+                    + "and have it send X-Forwarded-Proto: https. This is logged once per configuration change.");
+        }
+    }
+
+    /**
+     * Reports, once per configuration, a refused request that carried no {@code Origin}: if it came from a
+     * listed website embedding {@code context.js} with a {@code <script>} tag, the operator needs to know why
+     * that website keeps losing its visitors. Only once origins are listed: before that, every website is
+     * refused by design.
+     *
+     * @return whether the warning was logged by this call
+     */
+    public boolean reportRefusedRequestWithoutOrigin(boolean hasReferer) {
+        Resolved current = resolved;
+        boolean matchesReferer = current.policy.matchesReferer();
+        if (matchesReferer && hasReferer) {
+            // A Referer that is not listed: an unlisted website, nothing to fix.
+            return false;
+        }
+        if (!current.refusedEmbedToReport.get() || !current.refusedEmbedToReport.compareAndSet(true, false)) {
+            return false;
+        }
+        if (!matchesReferer) {
+            LOGGER.warn("Refused the visitor cookie for a cross-site request without an Origin header, such as a "
+                    + "<script src=\".../context.js\"> tag. If it came from a listed website, set "
+                    + "org.apache.unomi.public.cors.matchReferer=true. This is logged once per configuration change.");
+        } else {
+            LOGGER.warn("Refused the visitor cookie for a cross-site request without Origin or Referer headers. If it "
+                    + "came from a listed website, check that its Referrer-Policy sends the origin to other sites "
+                    + "(the default strict-origin-when-cross-origin does). This is logged once per configuration change.");
+        }
+        return true;
+    }
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
index 7a8be14df..1b33cc94e 100644
--- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
@@ -111,7 +111,7 @@ public class ClientEndpoint {
     }
 
     private Response donwloadCurrentProfile(String downloadFileType) throws JsonProcessingException {
-        String cookieProfileId = restServiceUtils.getProfileIdCookieValue(request);
+        String cookieProfileId = restServiceUtils.getProfileIdCookieValueForNavigation(request);
 
         if (cookieProfileId != null) {
             Profile currentProfile = profileService.load(cookieProfileId);
diff --git a/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java b/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
index 0f98aac21..e53bc7de0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
+++ b/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
@@ -27,7 +27,6 @@ import org.apache.cxf.jaxrs.openapi.OpenApiFeature;
 import org.apache.cxf.jaxrs.security.SimpleAuthorizingFilter;
 import org.apache.cxf.jaxrs.swagger.ui.SwaggerUiConfig;
 import org.apache.cxf.message.Message;
-import org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter;
 import dev.inoyu.openapi.enrich.cxf.BundleContextDocPackLoader;
 import dev.inoyu.openapi.enrich.cxf.EnrichingOpenApiCustomizer;
 import dev.inoyu.openapi.enrich.model.DocPack;
@@ -39,6 +38,8 @@ import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.apache.unomi.rest.cors.PublicTrackerCorsFilter;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
 import org.apache.unomi.rest.authentication.AuthenticationFilter;
 import org.apache.unomi.rest.authentication.AuthorizingInterceptor;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
@@ -83,6 +84,7 @@ public class RestServer {
     private RestAuthenticationConfig restAuthenticationConfig;
     private List<ExceptionMapper> exceptionMappers = new ArrayList<>();
     private ConfigSharingService configSharingService;
+    private PublicTrackerSettings publicTrackerSettings;
     private SchemaService schemaService;
     private TenantService tenantService;
     private SecurityService securityService;
@@ -137,6 +139,16 @@ public class RestServer {
         this.configSharingService = configSharingService;
     }
 
+    /**
+     * Sets the public tracker cookie settings.
+     *
+     * @param publicTrackerSettings the parsed allowed origins and SameSite setting
+     */
+    @Reference(cardinality = ReferenceCardinality.MANDATORY)
+    public void setPublicTrackerSettings(PublicTrackerSettings publicTrackerSettings) {
+        this.publicTrackerSettings = publicTrackerSettings;
+    }
+
     /**
      * Sets the the tenant service.
      *
@@ -400,7 +412,8 @@ public class RestServer {
         jaxrsServerFactoryBean.setAddress("/");
         jaxrsServerFactoryBean.setBus(serverBus);
         jaxrsServerFactoryBean.setProvider(new JacksonJaxbJsonProvider(objectMapper, JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS));
-        jaxrsServerFactoryBean.setProvider(new CrossOriginResourceSharingFilter());
+        // CXF's CORS filter, restricted so that only listed origins may send the visitor cookie.
+        jaxrsServerFactoryBean.setProvider(new PublicTrackerCorsFilter(publicTrackerSettings));
         jaxrsServerFactoryBean.setProvider(new RetroCompatibilityParamConverterProvider(objectMapper));
 
         // Authentication and Security filters in order of priority
diff --git a/rest/src/main/java/org/apache/unomi/rest/service/RestServiceUtils.java b/rest/src/main/java/org/apache/unomi/rest/service/RestServiceUtils.java
index d5c795a4c..c3ec6daab 100644
--- a/rest/src/main/java/org/apache/unomi/rest/service/RestServiceUtils.java
+++ b/rest/src/main/java/org/apache/unomi/rest/service/RestServiceUtils.java
@@ -77,7 +77,18 @@ public interface RestServiceUtils {
     /**
      * Try to extract the current visitor profileId from the current request cookies.
      * @param httpServletRequest the current HTTP request
-     * @return the profileId if found in the cookies, null otherwise
+     * @return the profileId if found in the cookies and the request comes from a page that may use the visitor
+     *         cookie (a listed website origin, Unomi's own origin, or no page at all), null otherwise
      */
     String getProfileIdCookieValue(HttpServletRequest httpServletRequest);
+
+    /**
+     * Like {@link #getProfileIdCookieValue(HttpServletRequest)}, but also accepts a top-level {@code GET}
+     * navigation, such as a "download my data" link on the website. Only for endpoints that return the
+     * visitor's own data and change nothing: the page that made the link cannot read the response.
+     *
+     * @param httpServletRequest the current HTTP request
+     * @return the profileId if found in the cookies and the request may use it, null otherwise
+     */
+    String getProfileIdCookieValueForNavigation(HttpServletRequest httpServletRequest);
 }
diff --git a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index 438ae8e20..c49f8835a 100644
--- a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -22,11 +22,11 @@ import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
 import org.apache.cxf.jaxrs.utils.JAXRSUtils;
 import org.apache.unomi.api.*;
 import org.apache.unomi.api.security.CompatPeerPrincipal;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.utils.LogSanitizer;
 import org.apache.unomi.api.security.TenantPrincipal;
 import org.apache.unomi.api.security.UnomiRoles;
-import org.apache.unomi.api.services.ConfigSharingService;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.PrivacyService;
 import org.apache.unomi.api.services.ProfileService;
@@ -34,6 +34,7 @@ import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
 import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
 import org.apache.unomi.services.common.security.SecurityUtils;
 import org.apache.unomi.rest.exception.InvalidRequestException;
 import org.apache.unomi.rest.service.RestServiceUtils;
@@ -66,14 +67,13 @@ import java.util.UUID;
 @Component(service = RestServiceUtils.class)
 public class RestServiceUtilsImpl implements RestServiceUtils {
 
+    private static final String VISITOR_COOKIE_ALLOWED_ATTRIBUTE = RestServiceUtilsImpl.class.getName() + ".visitorCookieAllowed";
+
     private static final String DEFAULT_CLIENT_ID = "defaultClientId";
 
     private static final Logger LOGGER = LoggerFactory.getLogger(RestServiceUtilsImpl.class.getName());
     public static final String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id";
 
-    @Reference
-    private ConfigSharingService configSharingService;
-
     @Reference
     private PrivacyService privacyService;
 
@@ -98,27 +98,96 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
     @Reference
     private SecurityService securityService;
 
+    @Reference
+    private PublicTrackerSettings publicTrackerSettings;
+
     @Override
     public String getProfileIdCookieValue(HttpServletRequest httpServletRequest) {
-        String cookieProfileId = null;
+        return readProfileIdCookie(httpServletRequest, false);
+    }
 
-        Cookie[] cookies = httpServletRequest.getCookies();
+    @Override
+    public String getProfileIdCookieValueForNavigation(HttpServletRequest httpServletRequest) {
+        return readProfileIdCookie(httpServletRequest, true);
+    }
 
-        if (cookies != null) {
-            final Object profileIdCookieName = configSharingService.getProperty("profileIdCookieName");
-            for (Cookie cookie : cookies) {
-                if (profileIdCookieName.equals(cookie.getName())) {
-                    String profileIdJSON = JsonNodeFactory.instance.objectNode().put("profileId", cookie.getValue()).toString();
-                    if (!schemaService.isValid(profileIdJSON, "https://unomi.apache.org/schemas/json/rest/requestIds/1-0-0")) {
-                        throw new InvalidRequestException("Invalid profile ID format in cookie", "Invalid received data");
-                    }
-                    cookieProfileId = cookie.getValue();
-                }
+    private String readProfileIdCookie(HttpServletRequest httpServletRequest, boolean acceptNavigation) {
+        Cookie[] cookies = httpServletRequest.getCookies();
+        if (cookies == null) {
+            return null;
+        }
+        final String profileIdCookieName = publicTrackerSettings.cookieName();
+        String cookieProfileId = null;
+        for (Cookie cookie : cookies) {
+            if (profileIdCookieName.equals(cookie.getName())) {
+                cookieProfileId = cookie.getValue();
             }
         }
+        if (cookieProfileId == null) {
+            return null;
+        }
+        // A link opens in the visitor's own window; no other page can read what it returns.
+        boolean navigation = acceptNavigation && "GET".equals(httpServletRequest.getMethod())
+                && "navigate".equals(httpServletRequest.getHeader("Sec-Fetch-Mode"))
+                && "document".equals(httpServletRequest.getHeader("Sec-Fetch-Dest"));
+        if (!navigation && !isVisitorCookieAllowed(httpServletRequest)) {
+            return null;
+        }
+        String profileIdJSON = JsonNodeFactory.instance.objectNode().put("profileId", cookieProfileId).toString();
+        if (!schemaService.isValid(profileIdJSON, "https://unomi.apache.org/schemas/json/rest/requestIds/1-0-0")) {
+            throw new InvalidRequestException("Invalid profile ID format in cookie", "Invalid received data");
+        }
         return cookieProfileId;
     }
 
+    /**
+     * Whether this request may use or receive the visitor cookie: it comes from a listed origin, from Unomi's
+     * own origin, or shows no sign of another site's page. A browser may still send a SameSite=None cookie
+     * from another site; that visitor must neither be bound nor have its cookie replaced. Decided once per
+     * request.
+     */
+    private boolean isVisitorCookieAllowed(HttpServletRequest request) {
+        Object decided = request.getAttribute(VISITOR_COOKIE_ALLOWED_ATTRIBUTE);
+        if (decided instanceof Boolean) {
+            return (Boolean) decided;
+        }
+        String origin = request.getHeader("Origin");
+        String referer = request.getHeader("Referer");
+        String secFetchSite = request.getHeader("Sec-Fetch-Site");
+        boolean allowed = publicTrackerSettings.policy().allowsVisitorCookie(
+                origin, referer, secFetchSite, request.getServerName(), request.getHeader("X-Forwarded-Host"));
+        if (!allowed) {
+            // Any site can cause this, so do not pay for sanitising the headers unless they are logged.
+            if (LOGGER.isDebugEnabled()) {
+                LOGGER.debug("Not using the profile cookie (Origin {}, Referer {}, Sec-Fetch-Site {}): neither a listed "
+                                + "website nor Unomi's own origin", LogSanitizer.forLogging(origin),
+                        LogSanitizer.forLogging(referer), LogSanitizer.forLogging(secFetchSite));
+            }
+            if (origin == null) {
+                publicTrackerSettings.reportRefusedRequestWithoutOrigin(referer != null);
+            }
+        }
+        request.setAttribute(VISITOR_COOKIE_ALLOWED_ATTRIBUTE, allowed);
+        return allowed;
+    }
+
+    /**
+     * Whether the visitor reached Unomi over HTTPS: the container says so, or a proxy reported it with
+     * {@code X-Forwarded-Proto} and {@link SecurityService#recordRequestTransport} accepted that proxy, so it
+     * did not mark the request subject as insecure. Both are required: a wrong {@code Secure} flag would make
+     * browsers drop the cookie over plain HTTP.
+     */
+    private boolean isSecureRequest(HttpServletRequest request) {
+        if (request.isSecure()) {
+            return true;
+        }
+        if (request.getHeader("X-Forwarded-Proto") == null) {
+            return false;
+        }
+        Subject subject = securityService.getRequestSubject();
+        return subject != null && subject.getPrincipals(InsecureTransportPrincipal.class).isEmpty();
+    }
+
     @Override
     public EventsRequestContext initEventsRequest(String scope, String sessionId, String profileId, String personaId,
                                                   boolean invalidateProfile, boolean invalidateSession,
@@ -493,10 +562,11 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
             }
         }
 
-        // Set profile cookie
-        if (!(eventsRequestContext.getProfile() instanceof Persona)) {
+        // Set profile cookie, unless the request comes from a page that may not use it
+        HttpServletRequest request = eventsRequestContext.getRequest();
+        if (!(eventsRequestContext.getProfile() instanceof Persona) && isVisitorCookieAllowed(request)) {
             eventsRequestContext.getResponse().setHeader("Set-Cookie",
-                    HttpUtils.getProfileCookieString(eventsRequestContext.getProfile(), configSharingService, eventsRequestContext.getRequest().isSecure()));
+                    publicTrackerSettings.profileCookie(eventsRequestContext.getProfile().getItemId(), isSecureRequest(request)));
         }
     }
 
diff --git a/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java b/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
index 8242c39c1..d0e986f55 100644
--- a/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
+++ b/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
@@ -21,10 +21,7 @@ import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
-import org.apache.commons.lang3.StringUtils;
 import org.apache.unomi.api.Event;
-import org.apache.unomi.api.Profile;
-import org.apache.unomi.api.services.ConfigSharingService;
 import org.apache.unomi.schema.api.SchemaService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -115,30 +112,6 @@ public class HttpUtils {
         return stringBuilder.toString();
     }
 
-    /**
-     * Builds a {@code Set-Cookie} header value for the profile ID cookie.
-     * <p>
-     * Uses a raw header string because JAX-RS {@code NewCookie} does not support {@code SameSite}.
-     *
-     * @param profile the profile whose ID is stored in the cookie
-     * @param configSharingService shared configuration for cookie name, domain, and flags
-     * @param isSecure whether the cookie should include the {@code Secure} flag
-     * @return the cookie header value
-     */
-    public static String getProfileCookieString(Profile profile, ConfigSharingService configSharingService, boolean isSecure) {
-        final String profileIdCookieDomain = (String) configSharingService.getProperty("profileIdCookieDomain");
-        final String profileIdCookieName = (String) configSharingService.getProperty("profileIdCookieName");
-        final Integer profileIdCookieMaxAgeInSeconds = (Integer) configSharingService.getProperty("profileIdCookieMaxAgeInSeconds");
-        final Boolean profileIdCookieHttpOnly = (Boolean) configSharingService.getProperty("profileIdCookieHttpOnly");
-        return profileIdCookieName + "=" + profile.getItemId() +
-                "; Path=/" +
-                "; Max-Age=" + profileIdCookieMaxAgeInSeconds +
-                (StringUtils.isNotBlank(profileIdCookieDomain) ? ("; Domain=" + profileIdCookieDomain) : "") +
-                "; SameSite=Lax" +
-                (isSecure ? "; Secure" : "") +
-                (profileIdCookieHttpOnly ? "; HttpOnly" : "");
-    }
-
     /**
      * Filters event nodes to those that pass schema validation.
      *
diff --git a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
index fe27728e9..82755d2f5 100644
--- a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
+++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
@@ -51,6 +51,17 @@ class ShippedProfileCookieConfigTest {
         String systemProperties = Files.readString(repoFile(SYSTEM_PROPERTIES_PATH));
         assertTrue(systemProperties.matches("(?s).*org\\.apache\\.unomi\\.profile\\.cookie\\.httpOnly=\\$\\{[^}]*:-true}.*"),
                 "profile cookie HttpOnly should default to true in " + SYSTEM_PROPERTIES_PATH);
+        assertTrue(systemProperties.contains("UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS"),
+                "public CORS origin list should be reachable from the environment");
+        assertTrue(systemProperties.contains("UNOMI_PROFILE_COOKIE_SAMESITE"),
+                "profile cookie SameSite should be reachable from the environment");
+        assertTrue(webCfg.contains("public.cors.allowedOrigins=${org.apache.unomi.public.cors.allowedOrigins:-}"),
+                "web config should read the public CORS origin list");
+        // Matching a listed page by Referer trusts what it frames, so it ships off.
+        assertTrue(webCfg.contains("public.cors.matchReferer=${org.apache.unomi.public.cors.matchReferer:-false}"),
+                "Referer matching should default to off in " + WEB_CFG_PATH);
+        assertTrue(systemProperties.contains("org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}"),
+                "Referer matching should default to off and be reachable from the environment");
     }
 
     private static Path repoFile(String relativePath) throws IOException {
diff --git a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
new file mode 100644
index 000000000..b109ccae2
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.UriInfo;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicTrackerCorsFilterTest {
+
+    @Mock
+    private ContainerRequestContext request;
+    @Mock
+    private ContainerResponseContext response;
+    @Mock
+    private UriInfo uriInfo;
+
+    private PublicTrackerCorsFilter filter;
+    private MultivaluedMap<String, Object> headers;
+
+    @BeforeEach
+    void setUp() {
+        listed(null);
+        headers = new MultivaluedHashMap<>();
+        lenient().when(request.getUriInfo()).thenReturn(uriInfo);
+        lenient().when(uriInfo.getPath()).thenReturn("context.json");
+        lenient().when(response.getHeaders()).thenReturn(headers);
+    }
+
+    /** What CXF's filter puts on a public tracker response: any origin, no credentials. */
+    private void grantedByCxf() {
+        headers.putSingle("Access-Control-Allow-Origin", "*");
+        headers.putSingle("Access-Control-Allow-Credentials", Boolean.FALSE);
+    }
+
+    private void listed(String origins) {
+        filter = new PublicTrackerCorsFilter(new PublicTrackerSettings(origins, null));
+    }
+
+    @Test
+    void listedOriginNamesOnlyThatOriginAndVaries() {
+        listed("https://shop.example");
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("https://shop.example", headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals("true", headers.getFirst("Access-Control-Allow-Credentials"));
+        assertEquals("Origin", headers.getFirst("Vary"));
+    }
+
+    @Test
+    void listedOriginPreflightAnsweredByCxfGetsCredentials() {
+        // CXF aborts the preflight with its own response; response filters still run on it.
+        listed("https://shop.example");
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+        headers.putSingle("Access-Control-Allow-Methods", "POST");
+        headers.putSingle("Access-Control-Allow-Headers", "content-type,x-unomi-api-key,x-unomi-tenant-id");
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("https://shop.example", headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals("true", headers.getFirst("Access-Control-Allow-Credentials"));
+        assertEquals("content-type,x-unomi-api-key,x-unomi-tenant-id", headers.getFirst("Access-Control-Allow-Headers"));
+    }
+
+    @Test
+    void existingVaryIsKeptAndOriginAddedOnce() {
+        listed("https://shop.example");
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+        headers.add("Vary", "Accept-Encoding");
+
+        filter.restrictCredentials(request, response);
+        filter.restrictCredentials(request, response);
+
+        assertEquals(java.util.Arrays.asList("Accept-Encoding", "Origin"), headers.get("Vary"));
+    }
+
+    @Test
+    void unlistedOriginDoesNotGetCredentials() {
+        listed("https://shop.example");
+        when(request.getHeaderString("Origin")).thenReturn("https://other.example");
+        headers.putSingle("Access-Control-Allow-Origin", "https://other.example");
+        headers.putSingle("Access-Control-Allow-Credentials", "true");
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertFalse(headers.containsKey("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void defaultSettingsAllowNoCredentials() {
+        listed(null);
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertNull(headers.getFirst("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void responseCxfDidNotGrantIsLeftAlone() {
+        // For example a failed preflight: CXF adds no CORS headers, and neither do we.
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+
+        filter.restrictCredentials(request, response);
+
+        assertTrue(headers.isEmpty());
+    }
+
+    @Test
+    void contextJsIsAPublicTrackerPath() {
+        when(uriInfo.getPath()).thenReturn("context.js");
+        listed("https://shop.example");
+        when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("https://shop.example", headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals("true", headers.getFirst("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void nonPublicPathIsLeftAlone() {
+        // A listed origin, so only the path check keeps the CXF grant as it is.
+        when(uriInfo.getPath()).thenReturn("profiles/search");
+        listed("https://shop.example");
+        lenient().when(request.getHeaderString("Origin")).thenReturn("https://shop.example");
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals(Boolean.FALSE, headers.getFirst("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void inheritsCxfPreMatchingAndPriority() {
+        // CXF reads both through the class hierarchy; the rewrite then needs no ordering of its own.
+        assertTrue(org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter.class.isAssignableFrom(PublicTrackerCorsFilter.class));
+        assertNotNull(org.apache.cxf.jaxrs.utils.AnnotationUtils.getClassAnnotation(
+                PublicTrackerCorsFilter.class, javax.ws.rs.container.PreMatching.class));
+        assertEquals(org.apache.cxf.jaxrs.utils.AnnotationUtils.getBindingPriority(
+                        org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter.class),
+                org.apache.cxf.jaxrs.utils.AnnotationUtils.getBindingPriority(PublicTrackerCorsFilter.class));
+    }
+
+    @Test
+    void requestWithoutOriginIsLeftAlone() {
+        when(request.getHeaderString("Origin")).thenReturn(null);
+
+        filter.restrictCredentials(request, response);
+
+        assertFalse(headers.containsKey("Access-Control-Allow-Origin"));
+        assertFalse(headers.containsKey("Access-Control-Allow-Credentials"));
+    }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
new file mode 100644
index 000000000..d5101db1a
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PublicTrackerCorsPolicyTest {
+
+    @Test
+    void defaultSettingsAllowNoOrigin() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(null);
+        assertTrue(policy.isEmpty());
+        assertFalse(policy.allows("https://shop.example"));
+        assertFalse(policy.allows(null));
+    }
+
+    @Test
+    void listedOriginIsExact() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(" https://shop.example , https://shop.example:8443 ");
+        assertTrue(policy.allows("https://shop.example"));
+        assertTrue(policy.allows("https://shop.example:8443"));
+        assertFalse(policy.allows("http://shop.example"));
+        assertFalse(policy.allows("https://shop.example:444"));
+        assertFalse(policy.allows("https://other.shop.example"));
+        assertFalse(policy.allows("https://shop.example.evil"));
+        assertFalse(policy.allows("null"));
+        assertFalse(policy.allows("*"));
+        assertFalse(policy.allows("https://shop.example/path"));
+    }
+
+    @Test
+    void listedOriginsAreComparedAsBrowsersSendThem() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(
+                "https://WWW.Shop.Example, https://cart.example:443, http://legacy.example:80, https://slash.example/, http://[::1]:8080");
+        assertTrue(policy.allows("https://www.shop.example"));
+        assertTrue(policy.allows("https://cart.example"));
+        assertTrue(policy.allows("http://legacy.example"));
+        assertTrue(policy.allows("https://slash.example"));
+        assertTrue(policy.allows("http://[::1]:8080"));
+        assertFalse(policy.allows("https://legacy.example"));
+        // Browsers send the serialized form; a non-browser variant is not looked up.
+        assertFalse(policy.allows("https://WWW.shop.example"));
+        assertFalse(policy.allows("https://cart.example:443"));
+    }
+
+    @Test
+    void invalidEntriesAreDropped() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(
+                "https://shop.example/path, ftp://shop.example, *.example, null, https://*.example,, https://ok.example");
+        assertTrue(policy.allows("https://ok.example"));
+        assertFalse(policy.allows("https://shop.example"));
+        assertFalse(policy.allows("null"));
+    }
+
+    @Test
+    void visitorCookieIsUsedWithoutOriginOrFromUnomisOwnOrigin() {
+        PublicTrackerCorsPolicy empty = PublicTrackerCorsPolicy.parse("");
+        assertTrue(empty.allowsVisitorCookie(null, null, null, "cdp.example", null));
+        // Browsers send Origin on same-origin POSTs.
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, "same-origin", "cdp.example", null));
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, "none", "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://www.cdp.example", null, "same-site", "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://evil.example", null, "cross-site", "cdp.example", null));
+        // <script> or <img> embedded by another site: no Origin, but the browser says cross-site.
+        assertFalse(empty.allowsVisitorCookie(null, null, "cross-site", "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie(null, null, "same-site", "cdp.example", null));
+        assertTrue(empty.allowsVisitorCookie(null, null, "same-origin", "cdp.example", null));
+        // A server-side caller sends neither header.
+        assertTrue(empty.allowsVisitorCookie(null, null, null, null, null));
+    }
+
+    @Test
+    void withoutSecFetchSiteTheOriginHostIsComparedWithTheRequestHost() {
+        PublicTrackerCorsPolicy empty = PublicTrackerCorsPolicy.parse("");
+        // A TLS-terminating proxy changes scheme and port, so only the host is compared.
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, null, "cdp.example", null));
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, null, "internal-host", "cdp.example:443"));
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, null, "internal-host", " CDP.example, proxy.internal"));
+        assertTrue(empty.allowsVisitorCookie("http://[::1]:8181", null, null, "[::1]", null));
+        assertFalse(empty.allowsVisitorCookie("https://www.cdp.example", null, null, "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://cdp.example.evil", null, null, "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://cdp", null, null, "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("null", null, null, "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://", null, null, "", null));
+        assertFalse(empty.allowsVisitorCookie("https://evil.example", null, null, null, null));
+    }
+
+    @Test
+    void secFetchSiteWinsOverAMatchingHost() {
+        PublicTrackerCorsPolicy empty = PublicTrackerCorsPolicy.parse("");
+        // Same host but the browser says another site: the host fallback must not apply.
+        assertFalse(empty.allowsVisitorCookie("https://cdp.example", null, "cross-site", "cdp.example", null));
+        assertFalse(empty.allowsVisitorCookie("https://cdp.example", null, "same-site", "cdp.example", null));
+        assertTrue(empty.allowsVisitorCookie("https://cdp.example", null, null, "cdp.example", null));
+    }
+
+    @Test
+    void refererOfAListedPageCountsWhenThereIsNoOriginAndMatchingIsOn() {
+        PublicTrackerCorsPolicy listed = PublicTrackerCorsPolicy.parse("https://shop.example, https://cart.example:8443, http://[::1]:8181", true, new ArrayList<>());
+        assertTrue(listed.allowsVisitorCookie(null, "https://shop.example/", "cross-site", "cdp.example", null));
+        assertTrue(listed.allowsVisitorCookie(null, "https://shop.example", "cross-site", "cdp.example", null));
+        assertTrue(listed.allowsVisitorCookie(null, "https://cart.example:8443/basket", "cross-site", "cdp.example", null));
+        assertTrue(listed.allowsVisitorCookie(null, "http://[::1]:8181/p", "cross-site", "cdp.example", null));
+        // The character after the listed origin must end it.
+        assertFalse(listed.allowsVisitorCookie(null, "https://shop.example.evil/", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, "https://shop.example:8443/", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, "https://cart.example:84430/", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, "https://shop.example@evil.example/", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, "https://shop.example?x", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, "https://other.example/?https://shop.example/", "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie(null, null, "cross-site", "cdp.example", null));
+        // A listed subdomain's <script> is same-site; the listed Referer still decides.
+        assertTrue(listed.allowsVisitorCookie(null, "https://shop.example/", "same-site", "cdp.example", null));
+        // Origin, when present, is what counts; Referer is not consulted.
+        assertFalse(listed.allowsVisitorCookie("https://other.example", "https://shop.example/", "cross-site", "cdp.example", null));
+    }
+
+    @Test
+    void refererIsIgnoredUnlessMatchingIsOn() {
+        // A listed page's sandboxed frame reports that page as Referer, so the match is opt-in.
+        PublicTrackerCorsPolicy listed = PublicTrackerCorsPolicy.parse("https://shop.example");
+        assertFalse(listed.matchesReferer());
+        assertFalse(listed.allowsVisitorCookie(null, "https://shop.example/", "cross-site", "cdp.example", null));
+        // An unlisted Referer never refuses what the other rules allow.
+        assertTrue(listed.allowsVisitorCookie(null, "https://evil.example/", "same-origin", "cdp.example", null));
+        assertTrue(PublicTrackerCorsPolicy.parse("https://shop.example", true, new ArrayList<>())
+                .allowsVisitorCookie(null, "https://evil.example/", "none", "cdp.example", null));
+    }
+
+    @Test
+    void listedOriginUsesTheVisitorCookieFromAnotherSite() {
+        PublicTrackerCorsPolicy listed = PublicTrackerCorsPolicy.parse("https://shop.example");
+        assertTrue(listed.allowsVisitorCookie("https://shop.example", null, "cross-site", "cdp.example", null));
+        assertFalse(listed.allowsVisitorCookie("https://other.example", null, "cross-site", "cdp.example", null));
+    }
+
+    @Test
+    void wildcardIsNeverAnAllowedOrigin() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse("*");
+        assertTrue(policy.isEmpty());
+        assertFalse(policy.allows("*"));
+    }
+
+    @Test
+    void sameSiteNoneRequiresSecureAndOrigins() {
+        PublicTrackerCorsPolicy empty = PublicTrackerCorsPolicy.parse("");
+        assertEquals("Lax", empty.resolveSameSite(null, false));
+        assertEquals("Lax", empty.resolveSameSite("None", true));
+        assertEquals("Lax", empty.resolveSameSite("Lax", false));
+
+        PublicTrackerCorsPolicy listed = PublicTrackerCorsPolicy.parse("https://shop.example");
+        assertEquals("Lax", listed.resolveSameSite("None", false));
+        assertEquals("None", listed.resolveSameSite("None", true));
+        assertEquals("Strict", listed.resolveSameSite("Strict", false));
+        assertEquals("None", listed.resolveSameSite(" none ", true));
+        assertEquals("Lax", listed.resolveSameSite("bogus", true));
+    }
+
+    @Test
+    void publicTrackerPaths() {
+        assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("context.json"));
+        assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("context.js"));
+        assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("/eventcollector"));
+        assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("client/myapp/status"));
+        assertFalse(PublicTrackerCorsPolicy.isPublicTrackerPath("profiles/test"));
+        assertFalse(PublicTrackerCorsPolicy.isPublicTrackerPath("context.jsonx"));
+        assertFalse(PublicTrackerCorsPolicy.isPublicTrackerPath("clients"));
+        assertFalse(PublicTrackerCorsPolicy.isPublicTrackerPath(null));
+    }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java
new file mode 100644
index 000000000..925d7dee4
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeEvent;
+import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeEvent.ConfigChangeEventType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicTrackerSettingsTest {
+
+    @Mock
+    private ConfigSharingService configSharingService;
+
+    private PublicTrackerSettings settings;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        settings = new PublicTrackerSettings();
+        Field field = PublicTrackerSettings.class.getDeclaredField("configSharingService");
+        field.setAccessible(true);
+        field.set(settings, configSharingService);
+    }
+
+    @Test
+    void readsTheSettingsAtActivation() {
+        when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        when(configSharingService.getProperty(PublicTrackerSettings.SAME_SITE_PROPERTY)).thenReturn(" None ");
+
+        settings.activate();
+
+        assertTrue(settings.policy().allows("https://shop.example"));
+        assertTrue(settings.profileCookie("p1", true).contains("; SameSite=None; Secure"));
+    }
+
+    @Test
+    void defaultsWhenNothingIsConfigured() {
+        settings.activate();
+
+        assertTrue(settings.policy().isEmpty());
+        assertEquals("context-profile-id=p1; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly", settings.profileCookie("p1", false));
+    }
+
+    @Test
+    void reparsesOnlyWhenOneOfItsPropertiesChanges() {
+        settings.activate();
+        PublicTrackerCorsPolicy before = settings.policy();
+        clearInvocations(configSharingService);
+
+        settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.UPDATED, "publicPostRequestBytesLimit", 1, 2));
+        assertSame(before, settings.policy());
+        verifyNoInteractions(configSharingService);
+
+        when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.UPDATED,
+                PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY, "", "https://shop.example"));
+        assertTrue(settings.policy().allows("https://shop.example"));
+
+        when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn(null);
+        settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.REMOVED,
+                PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY, "https://shop.example", null));
+        assertFalse(settings.policy().allows("https://shop.example"));
+    }
+
+    @Test
+    void cookieAttributesFollowTheConfiguration() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_NAME_PROPERTY)).thenReturn("visitor");
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_DOMAIN_PROPERTY)).thenReturn("example.com");
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY)).thenReturn(60);
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY)).thenReturn(Boolean.FALSE);
+
+        settings.activate();
+
+        assertEquals("visitor", settings.cookieName());
+        assertEquals("visitor=p1; Path=/; Max-Age=60; Domain=example.com; SameSite=Lax; Secure", settings.profileCookie("p1", true));
+        assertEquals("visitor=p1; Path=/; Max-Age=60; Domain=example.com; SameSite=Lax", settings.profileCookie("p1", false));
+    }
+
+    @Test
+    void cookieChangeIsPickedUp() {
+        settings.activate();
+        assertEquals("context-profile-id", settings.cookieName());
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_NAME_PROPERTY)).thenReturn("renamed");
+        settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.UPDATED,
+                PublicTrackerSettings.COOKIE_NAME_PROPERTY, "context-profile-id", "renamed"));
+
+        assertEquals("renamed", settings.cookieName());
+    }
+
+    @Test
+    void sameSiteIsResolvedForBothTransports() {
+        PublicTrackerSettings none = new PublicTrackerSettings("https://shop.example", "none");
+        assertTrue(none.profileCookie("p1", true).contains("SameSite=None; Secure"));
+        assertTrue(none.profileCookie("p1", false).contains("SameSite=Lax"));
+        assertTrue(new PublicTrackerSettings("", "None").profileCookie("p1", true).contains("SameSite=Lax; Secure"));
+        assertTrue(new PublicTrackerSettings("", "Strict").profileCookie("p1", false).contains("SameSite=Strict"));
+    }
+
+    @Test
+    void httpOnlyIsOnlyTurnedOffByAnExplicitFalse() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY)).thenReturn("yes");
+        settings.activate();
+        assertTrue(settings.profileCookie("p1", false).endsWith("; HttpOnly"));
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY)).thenReturn(" FALSE ");
+        settings.activate();
+        assertFalse(settings.profileCookie("p1", false).contains("HttpOnly"));
+    }
+
+    @Test
+    void unusableMaxAgeFallsBackToTheDefault() {
+        for (Object value : new Object[]{0, -1, "soon", " 120 "}) {
+            lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY)).thenReturn(value);
+            settings.activate();
+            String expected = " 120 ".equals(value) ? "Max-Age=120;" : "Max-Age=31536000;";
+            assertTrue(settings.profileCookie("p1", false).contains(expected), String.valueOf(value));
+        }
+    }
+
+    @Test
+    void everyWatchedPropertyTriggersAReload() {
+        for (String property : new String[]{PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY, PublicTrackerSettings.SAME_SITE_PROPERTY,
+                PublicTrackerSettings.MATCH_REFERER_PROPERTY,
+                PublicTrackerSettings.COOKIE_NAME_PROPERTY, PublicTrackerSettings.COOKIE_DOMAIN_PROPERTY,
+                PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY, PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY}) {
+            clearInvocations(configSharingService);
+            settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.UPDATED, property, null, "x"));
+            verify(configSharingService).getProperty(PublicTrackerSettings.COOKIE_NAME_PROPERTY);
+        }
+    }
+
+    @Test
+    void refererMatchingIsOnlyTurnedOnByAnExplicitTrue() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        settings.activate();
+        assertFalse(settings.policy().matchesReferer());
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn("yes");
+        settings.activate();
+        assertFalse(settings.policy().matchesReferer());
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn(Boolean.TRUE);
+        settings.activate();
+        assertTrue(settings.policy().matchesReferer());
+    }
+
+    @Test
+    void aListWithNoValidEntryAllowsNoOriginAndSaysSo() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("shop.example, *");
+        settings.activate();
+        assertFalse(settings.policy().allows("https://shop.example"));
+        assertTrue(hasWarning("Ignoring public CORS allowed origin 'shop.example'"));
+        assertTrue(hasWarning("Ignoring public CORS allowed origin '*'"));
+        assertTrue(hasWarning("None of the public CORS allowed origins"));
+    }
+
+    @Test
+    void misconfiguredSettingsAreReported() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn("yes");
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY)).thenReturn(0);
+        settings.activate();
+        assertTrue(hasWarning("Unrecognised value 'yes' for org.apache.unomi.public.cors.matchReferer"));
+        assertTrue(hasWarning("Invalid profile cookie max age '0'"));
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn("true");
+        settings.activate();
+        assertTrue(hasWarning("matchReferer is on but no website origin is listed"));
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn(Boolean.FALSE);
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY)).thenReturn(60);
+        settings.activate();
+        assertTrue(settings.configurationWarnings().isEmpty(), String.valueOf(settings.configurationWarnings()));
+    }
+
+    @Test
+    void refusedRequestWithoutOriginIsReportedOncePerConfiguration() {
+        // Nothing listed: every website is refused by design, nothing to report.
+        assertFalse(new PublicTrackerSettings("", null).reportRefusedRequestWithoutOrigin(false));
+
+        PublicTrackerSettings listed = new PublicTrackerSettings("https://shop.example", null);
+        assertTrue(listed.reportRefusedRequestWithoutOrigin(true));
+        assertFalse(listed.reportRefusedRequestWithoutOrigin(false));
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        settings.activate();
+        assertTrue(settings.reportRefusedRequestWithoutOrigin(false));
+        assertFalse(settings.reportRefusedRequestWithoutOrigin(false));
+        // A configuration change re-arms it.
+        settings.configChanged(new ConfigChangeEvent(ConfigChangeEventType.UPDATED,
+                PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY, "", "https://shop.example"));
+        assertTrue(settings.reportRefusedRequestWithoutOrigin(false));
+    }
+
+    @Test
+    void anUnlistedRefererDoesNotUseUpTheReportWhenMatchingIsOn() {
+        PublicTrackerSettings matching = new PublicTrackerSettings("https://shop.example", null, true);
+        // An unlisted website: nothing to fix, and the report stays available.
+        assertFalse(matching.reportRefusedRequestWithoutOrigin(true));
+        // A listed website whose Referrer-Policy strips the Referer: reported.
+        assertTrue(matching.reportRefusedRequestWithoutOrigin(false));
+        assertFalse(matching.reportRefusedRequestWithoutOrigin(false));
+    }
+
+    @Test
+    void onlyWarningsTheConfigurationDidNotAlreadyHaveAreLogged() {
+        // WebConfig publishes one setting at a time; a problem must be logged once, not once per setting.
+        List<String> before = List.of("bad origin", "bad max age");
+        assertEquals(List.of(), PublicTrackerSettings.newWarnings(before, before));
+        assertEquals(List.of("bad referer"),
+                PublicTrackerSettings.newWarnings(before, List.of("bad origin", "bad referer")));
+        assertEquals(before, PublicTrackerSettings.newWarnings(List.of(), before));
+    }
+
+    @Test
+    void unrecognisedHttpOnlyValueKeepsItOnAndSaysSo() {
+        // WebConfig publishes the raw text, so a typo reaches this point instead of becoming false.
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY)).thenReturn("flase");
+        settings.activate();
+        assertTrue(settings.profileCookie("p1", false).endsWith("; HttpOnly"));
+        assertTrue(hasWarning("Unrecognised value 'flase' for org.apache.unomi.profile.cookie.httpOnly"));
+    }
+
+    @Test
+    void refusedSameSiteNoneIsReported() {
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.SAME_SITE_PROPERTY)).thenReturn("None");
+        settings.activate();
+        assertTrue(hasWarning("Refusing SameSite=None"));
+
+        lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn("https://shop.example");
+        settings.activate();
+        assertFalse(hasWarning("Refusing SameSite=None"));
+    }
+
+    private boolean hasWarning(String start) {
+        return settings.configurationWarnings().stream().anyMatch(warning -> warning.contains(start));
+    }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/endpoints/ClientEndpointTest.java b/rest/src/test/java/org/apache/unomi/rest/endpoints/ClientEndpointTest.java
new file mode 100644
index 000000000..d1870f67b
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/endpoints/ClientEndpointTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.endpoints;
+
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.rest.service.RestServiceUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.Response;
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The "download my data" endpoint must read the cookie through the navigation-aware lookup, so that a
+ * plain link on the website keeps the visitor (UNOMI-1005).
+ */
+@ExtendWith(MockitoExtension.class)
+class ClientEndpointTest {
+
+    @Mock private ProfileService profileService;
+    @Mock private ConfigSharingService configSharingService;
+    @Mock private RestServiceUtils restServiceUtils;
+    @Mock private HttpServletRequest request;
+    @Mock private HttpServletResponse response;
+
+    private ClientEndpoint endpoint;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        endpoint = new ClientEndpoint();
+        set("profileService", profileService);
+        set("configSharingService", configSharingService);
+        set("restServiceUtils", restServiceUtils);
+        set("request", request);
+        set("response", response);
+        when(configSharingService.getProperty("allowedProfileDownloadFormats")).thenReturn("csv,yaml,json,text");
+    }
+
+    @Test
+    void downloadUsesTheNavigationAwareCookieLookup() throws Exception {
+        Profile profile = new Profile("cookie-profile");
+        when(restServiceUtils.getProfileIdCookieValueForNavigation(request)).thenReturn("cookie-profile");
+        when(profileService.load("cookie-profile")).thenReturn(profile);
+
+        Response answer = endpoint.getClient("myprofile", "json");
+
+        assertEquals(200, answer.getStatus());
+        verify(response).setHeader("Content-Disposition", "attachment; filename=\"my-profile.json\"");
+        verify(restServiceUtils, never()).getProfileIdCookieValue(any());
+    }
+
+    @Test
+    void refusedCookieAnswersNotFound() {
+        when(restServiceUtils.getProfileIdCookieValueForNavigation(request)).thenReturn(null);
+
+        assertThrows(NotFoundException.class, () -> endpoint.getClient("myprofile", "json"));
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field field = ClientEndpoint.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(endpoint, value);
+    }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
index 293e09c95..f98f7fa70 100644
--- a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
+++ b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -22,13 +22,14 @@ import org.apache.unomi.api.PersonaWithSessions;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.Session;
 import org.apache.unomi.api.security.CompatPeerPrincipal;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.security.UnomiRoles;
-import org.apache.unomi.api.services.ConfigSharingService;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.PrivacyService;
 import org.apache.unomi.api.services.ProfileService;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
 import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
 import org.apache.unomi.schema.api.SchemaService;
 import org.apache.unomi.utils.EventsRequestContext;
@@ -47,18 +48,28 @@ import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -70,7 +81,6 @@ class RestServiceUtilsImplProfileBindingTest {
 
     private static final String COOKIE_NAME = "context-profile-id";
 
-    @Mock private ConfigSharingService configSharingService;
     @Mock private PrivacyService privacyService;
     @Mock private EventService eventService;
     @Mock private ProfileService profileService;
@@ -86,7 +96,6 @@ class RestServiceUtilsImplProfileBindingTest {
     @BeforeEach
     void setUp() throws Exception {
         restServiceUtils = new RestServiceUtilsImpl();
-        setField(restServiceUtils, "configSharingService", configSharingService);
         setField(restServiceUtils, "privacyService", privacyService);
         setField(restServiceUtils, "eventService", eventService);
         setField(restServiceUtils, "profileService", profileService);
@@ -94,8 +103,8 @@ class RestServiceUtilsImplProfileBindingTest {
         setField(restServiceUtils, "restAuthenticationConfig", restAuthenticationConfig);
         setField(restServiceUtils, "v2ThirdPartyConfigService", v2ThirdPartyConfigService);
         setField(restServiceUtils, "securityService", securityService);
+        allowedOrigins(null);
 
-        lenient().when(configSharingService.getProperty("profileIdCookieName")).thenReturn(COOKIE_NAME);
         lenient().when(schemaService.isValid(anyString(), anyString())).thenReturn(true);
         lenient().when(securityService.hasSystemAccess()).thenReturn(false);
         lenient().when(securityService.hasCompatPeerAbility(anyString())).thenReturn(false);
@@ -103,6 +112,298 @@ class RestServiceUtilsImplProfileBindingTest {
         lenient().when(privacyService.isRequireAnonymousBrowsing(org.mockito.ArgumentMatchers.any(Profile.class))).thenReturn(false);
     }
 
+    @Test
+    void initEventsRequest_unlistedOriginDoesNotUseVisitorCookie() {
+        allowedOrigins("https://shop.example");
+        when(request.getHeader("Origin")).thenReturn("https://other.example");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        try {
+            restServiceUtils.initEventsRequest(
+                    "systemscope", null, null, null,
+                    false, false, request, response, new Date());
+            fail("An unlisted origin must not be served as the visitor named by the cookie");
+        } catch (javax.ws.rs.BadRequestException expected) {
+            assertTrue(expected.getMessage().contains("profileId"));
+        }
+
+        verify(profileService, never()).load("cookie-profile");
+    }
+
+    @Test
+    void initEventsRequest_listedOriginUsesVisitorCookie() {
+        allowedOrigins("https://shop.example");
+        when(request.getHeader("Origin")).thenReturn("https://shop.example");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(new Profile("cookie-profile"));
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+    }
+
+    @Test
+    void initEventsRequest_sameOriginPostUsesVisitorCookieWithDefaultSettings() {
+        // Browsers send Origin on same-origin POSTs; that must not drop the cookie.
+        allowedOrigins("");
+        lenient().when(request.getHeader("Origin")).thenReturn("https://site.example");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("same-origin");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(new Profile("cookie-profile"));
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+    }
+
+    @Test
+    void initEventsRequest_sameHostWithoutSecFetchSiteUsesVisitorCookie() {
+        // Older browsers send no Sec-Fetch-Site; the reverse proxy forwards the public host.
+        allowedOrigins(null);
+        lenient().when(request.getHeader("Origin")).thenReturn("https://site.example");
+        when(request.getServerName()).thenReturn("10.0.0.5");
+        lenient().when(request.getHeader("X-Forwarded-Host")).thenReturn("site.example");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(new Profile("cookie-profile"));
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+    }
+
+    @Test
+    void getProfileIdCookieValue_unlistedSubdomainDoesNotUseVisitorCookie() {
+        allowedOrigins("");
+        lenient().when(request.getHeader("Origin")).thenReturn("https://www.site.example");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("same-site");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+        verify(schemaService, never()).isValid(anyString(), anyString());
+    }
+
+    @Test
+    void getProfileIdCookieValue_noCookieSkipsTheOriginCheck() {
+        when(request.getCookies()).thenReturn(null);
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+        verify(request, never()).getHeader("Origin");
+    }
+
+    @Test
+    void finalizeEventsRequest_cookieIsSecureBehindTrustedTlsProxy() {
+        when(request.isSecure()).thenReturn(false);
+        // No InsecureTransportPrincipal: a trusted proxy reported X-Forwarded-Proto: https.
+        lenient().when(request.getHeader("X-Forwarded-Proto")).thenReturn("https");
+        when(securityService.getRequestSubject()).thenReturn(new Subject());
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response).setHeader(eq("Set-Cookie"),
+                contains("; Secure"));
+    }
+
+    @Test
+    void finalizeEventsRequest_cookieIsNotSecureOverPlainHttp() {
+        when(request.isSecure()).thenReturn(false);
+        // An untrusted client or proxy claimed HTTPS; the authentication filter marked the subject insecure.
+        lenient().when(request.getHeader("X-Forwarded-Proto")).thenReturn("https");
+        Subject insecure = new Subject();
+        insecure.getPrincipals().add(InsecureTransportPrincipal.INSTANCE);
+        when(securityService.getRequestSubject()).thenReturn(insecure);
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response).setHeader(eq("Set-Cookie"),
+                argThat((String cookie) -> !cookie.contains("Secure")));
+    }
+
+    @Test
+    void finalizeEventsRequest_unmarkedSubjectWithoutForwardedProtoIsNotSecure() {
+        // Only a proxy's X-Forwarded-Proto, vouched for by the authentication filter, makes plain HTTP secure.
+        when(request.isSecure()).thenReturn(false);
+        lenient().when(securityService.getRequestSubject()).thenReturn(new Subject());
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response).setHeader(eq("Set-Cookie"),
+                argThat((String cookie) -> !cookie.contains("Secure")));
+    }
+
+    @Test
+    void finalizeEventsRequest_refusedCrossSiteRequestDoesNotReplaceTheCookie() {
+        allowedOrigins("https://shop.example");
+        lenient().when(request.getHeader("Origin")).thenReturn("https://other.example");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response, never()).setHeader(eq("Set-Cookie"), anyString());
+    }
+
+    @Test
+    void finalizeEventsRequest_embeddedCrossSiteRequestWithoutOriginDoesNotSetTheCookie() {
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        lenient().when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response, never()).setHeader(eq("Set-Cookie"), anyString());
+    }
+
+    @Test
+    void visitorCookieDecisionIsMadeOncePerRequest() {
+        // Binding and Set-Cookie must agree, and the headers are read only once.
+        Map<String, Object> attributes = new HashMap<>();
+        doAnswer(call -> attributes.put(call.getArgument(0), call.getArgument(1)))
+                .when(request).setAttribute(anyString(), any());
+        lenient().when(request.getAttribute(anyString())).thenAnswer(call -> attributes.get(call.getArgument(0)));
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        lenient().when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(request, times(1)).getHeader("Sec-Fetch-Site");
+        verify(response, never()).setHeader(eq("Set-Cookie"), anyString());
+    }
+
+    @Test
+    void getProfileIdCookieValue_listedSiteEmbeddingTheTrackerScriptKeepsTheVisitor() {
+        // <script src=".../context.js"> sends no Origin; the browser-set Referer names the listed page.
+        allowedOrigins("https://shop.example", true);
+        lenient().when(request.getHeader("Referer")).thenReturn("https://shop.example/products/42");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertEquals("cookie-profile", restServiceUtils.getProfileIdCookieValue(request));
+    }
+
+    @Test
+    void getProfileIdCookieValue_listedRefererIsIgnoredWhenMatchingIsOff() {
+        allowedOrigins("https://shop.example");
+        lenient().when(request.getHeader("Referer")).thenReturn("https://shop.example/products/42");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+    }
+
+    @Test
+    void finalizeEventsRequest_listedRefererSetsTheCookie() {
+        allowedOrigins("https://shop.example", true);
+        lenient().when(request.getHeader("Referer")).thenReturn("https://shop.example/products/42");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response).setHeader(eq("Set-Cookie"), contains("cookie-profile"));
+    }
+
+    @Test
+    void finalizeEventsRequest_unlistedRefererDoesNotSetTheCookie() {
+        // Sec-Fetch-Site alone refuses this too; the matching itself is pinned in PublicTrackerCorsPolicyTest.
+        allowedOrigins("https://shop.example", true);
+        lenient().when(request.getHeader("Referer")).thenReturn("https://evil.example/");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+
+        restServiceUtils.finalizeEventsRequest(contextWithProfile(), false);
+
+        verify(response, never()).setHeader(eq("Set-Cookie"), anyString());
+    }
+
+    @Test
+    void refusedRequestWithoutOriginIsReportedToTheSettings() throws Exception {
+        PublicTrackerSettings settings = spy(new PublicTrackerSettings("https://shop.example", null));
+        setField(restServiceUtils, "publicTrackerSettings", settings);
+        lenient().when(request.getHeader("Referer")).thenReturn("https://shop.example/");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+
+        verify(settings).reportRefusedRequestWithoutOrigin(true);
+    }
+
+    @Test
+    void refusedRequestWithAnOriginIsNotReportedAsAnEmbed() throws Exception {
+        PublicTrackerSettings settings = spy(new PublicTrackerSettings("https://shop.example", null));
+        setField(restServiceUtils, "publicTrackerSettings", settings);
+        lenient().when(request.getHeader("Origin")).thenReturn("https://other.example");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+
+        verify(settings, never()).reportRefusedRequestWithoutOrigin(anyBoolean());
+    }
+
+    @Test
+    void downloadLinkFromTheWebsiteKeepsTheVisitor() {
+        // <a href=".../cxs/client/myprofile/json"> on a listed or same-site page: no Origin, a navigation.
+        allowedOrigins("https://shop.example");
+        lenient().when(request.getMethod()).thenReturn("GET");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        lenient().when(request.getHeader("Sec-Fetch-Mode")).thenReturn("navigate");
+        lenient().when(request.getHeader("Sec-Fetch-Dest")).thenReturn("document");
+        lenient().when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        assertEquals("cookie-profile", restServiceUtils.getProfileIdCookieValueForNavigation(request));
+        // The tracker endpoints never accept a navigation, which could push events into the visitor's profile.
+        assertNull(restServiceUtils.getProfileIdCookieValue(request));
+    }
+
+    @Test
+    void framedOrScriptedDownloadIsNotANavigation() {
+        lenient().when(request.getMethod()).thenReturn("GET");
+        lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+        lenient().when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+        lenient().when(request.getHeader("Sec-Fetch-Mode")).thenReturn("navigate");
+        lenient().when(request.getHeader("Sec-Fetch-Dest")).thenReturn("iframe");
+        assertNull(restServiceUtils.getProfileIdCookieValueForNavigation(request));
+
+        lenient().when(request.getHeader("Sec-Fetch-Mode")).thenReturn("cors");
+        lenient().when(request.getHeader("Sec-Fetch-Dest")).thenReturn("empty");
+        assertNull(restServiceUtils.getProfileIdCookieValueForNavigation(request));
+
+        // A document destination that is not a navigation (for example a no-cors subresource).
+        lenient().when(request.getHeader("Sec-Fetch-Mode")).thenReturn("no-cors");
+        lenient().when(request.getHeader("Sec-Fetch-Dest")).thenReturn("document");
+        assertNull(restServiceUtils.getProfileIdCookieValueForNavigation(request));
+
+        lenient().when(request.getMethod()).thenReturn("POST");
+        lenient().when(request.getHeader("Sec-Fetch-Mode")).thenReturn("navigate");
+        lenient().when(request.getHeader("Sec-Fetch-Dest")).thenReturn("document");
+        assertNull(restServiceUtils.getProfileIdCookieValueForNavigation(request));
+    }
+
+    private void allowedOrigins(String origins) {
+        allowedOrigins(origins, false);
+    }
+
+    private void allowedOrigins(String origins, boolean matchReferer) {
+        try {
+            setField(restServiceUtils, "publicTrackerSettings", new PublicTrackerSettings(origins, null, matchReferer));
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+
+    private EventsRequestContext contextWithProfile() {
+        EventsRequestContext ctx = new EventsRequestContext(new Date(), null, null, request, response);
+        ctx.setProfile(new Profile("cookie-profile"));
+        return ctx;
+    }
+
     @Test
     void initEventsRequest_doesNotAuthenticatePeerWhenCompatOff() {
         Profile cookieProfile = new Profile("cookie-profile");
diff --git a/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java b/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java
new file mode 100644
index 000000000..993da7d69
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.utils;
+
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ProfileCookieSameSiteTest {
+
+    @Test
+    void defaultCookieIsSameSiteLax() {
+        String cookie = cookie(settings(null, null), false);
+        assertTrue(cookie.contains("SameSite=Lax"));
+        assertFalse(cookie.contains("SameSite=None"));
+    }
+
+    @Test
+    void sameSiteNoneIsRefusedWithoutSecureOrOrigins() {
+        String withoutSecure = cookie(settings("None", "https://shop.example"), false);
+        assertTrue(withoutSecure.contains("SameSite=Lax"));
+
+        String withoutOrigins = cookie(settings("None", ""), true);
+        assertTrue(withoutOrigins.contains("SameSite=Lax"));
+        assertTrue(withoutOrigins.contains("Secure"));
+    }
+
+    @Test
+    void sameSiteNoneIsAcceptedWithSecureAndOrigins() {
+        String cookie = cookie(settings("None", "https://shop.example"), true);
+        assertTrue(cookie.contains("SameSite=None"));
+        assertTrue(cookie.contains("Secure"));
+    }
+
+    @Test
+    void sameSiteValueIsCaseInsensitiveAndUnknownValuesFallBackToLax() {
+        assertTrue(cookie(settings(" none ", "https://shop.example"), true).contains("SameSite=None"));
+        assertTrue(cookie(settings("strict", ""), false).contains("SameSite=Strict"));
+        assertTrue(cookie(settings("bogus", ""), false).contains("SameSite=Lax"));
+    }
+
+    @Test
+    void refusedSameSiteNoneStillWritesACookieOnEveryRequest() {
+        // The warning is logged once; the fallback must apply every time.
+        for (int i = 0; i < 3; i++) {
+            assertTrue(cookie(settings("None", ""), true).contains("SameSite=Lax"));
+        }
+    }
+
+    @Test
+    void cookieAttributesArePrecomputedInTheShippedOrder() {
+        assertEquals("context-profile-id=profile-1; Path=/; Max-Age=31536000; SameSite=None; Secure; HttpOnly",
+                cookie(settings("None", "https://shop.example"), true));
+        assertEquals("context-profile-id=profile-1; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly",
+                cookie(settings("None", "https://shop.example"), false));
+    }
+
+    private static String cookie(PublicTrackerSettings settings, boolean secure) {
+        return settings.profileCookie("profile-1", secure);
+    }
+
+    private static PublicTrackerSettings settings(String sameSite, String origins) {
+        return new PublicTrackerSettings(origins, sameSite);
+    }
+}
diff --git a/web-servlets/pom.xml b/web-servlets/pom.xml
index 4c9045faa..e08f4294c 100644
--- a/web-servlets/pom.xml
+++ b/web-servlets/pom.xml
@@ -155,6 +155,17 @@
             <groupId>com.opencsv</groupId>
             <artifactId>opencsv</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
index 8932eac7d..cf4a88567 100644
--- a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
+++ b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
@@ -38,7 +38,10 @@ public class WebConfig {
     private String contextserverDomain;
     private String contexserverProfileIdCookieName;
     private int contextserverProfileIdCookieMaxAgeInSeconds;
-    private boolean contextserverProfileIdCookieHttpOnly;
+    private String contextserverProfileIdCookieHttpOnly;
+    private String contextserverProfileIdCookieSameSite;
+    private String publicCorsAllowedOrigins;
+    private String publicCorsMatchReferer;
     private String allowedProfileDownloadFormats;
     private int publicPostRequestBytesLimit;
 
@@ -57,8 +60,20 @@ public class WebConfig {
         @AttributeDefinition
         int contextserver_profileIdCookieMaxAgeInSeconds() default 31536000;
 
+        // Strings, not booleans: the framework would turn a typo such as "flase" into false, switching
+        // HttpOnly off. PublicTrackerSettings keeps HttpOnly on unless the value is exactly false, and
+        // reports a matchReferer value it does not recognise.
         @AttributeDefinition
-        boolean contextserver_profileIdCookieHttpOnly() default true;
+        String contextserver_profileIdCookieHttpOnly() default "true";
+
+        @AttributeDefinition
+        String contextserver_profileIdCookieSameSite() default "Lax";
+
+        @AttributeDefinition
+        String public_cors_allowedOrigins() default "";
+
+        @AttributeDefinition
+        String public_cors_matchReferer() default "false";
 
         @AttributeDefinition
         String allowed_profile_download_formats() default "csv,yaml,json,text";
@@ -78,6 +93,9 @@ public class WebConfig {
         this.contexserverProfileIdCookieName = config.contexserver_profileIdCookieName();
         this.contextserverProfileIdCookieMaxAgeInSeconds = config.contextserver_profileIdCookieMaxAgeInSeconds();
         this.contextserverProfileIdCookieHttpOnly = config.contextserver_profileIdCookieHttpOnly();
+        this.contextserverProfileIdCookieSameSite = config.contextserver_profileIdCookieSameSite();
+        this.publicCorsAllowedOrigins = config.public_cors_allowedOrigins();
+        this.publicCorsMatchReferer = config.public_cors_matchReferer();
         this.allowedProfileDownloadFormats = config.allowed_profile_download_formats();
         this.publicPostRequestBytesLimit = config.public_post_request_bytes_limit();
 
@@ -85,6 +103,11 @@ public class WebConfig {
         configSharingService.setProperty("profileIdCookieDomain", contextserverDomain);
         configSharingService.setProperty("profileIdCookieMaxAgeInSeconds", contextserverProfileIdCookieMaxAgeInSeconds);
         configSharingService.setProperty("profileIdCookieHttpOnly", contextserverProfileIdCookieHttpOnly);
+        // Origins before SameSite: each property change re-resolves the cookie settings, and SameSite=None
+        // with no origins yet would be refused, with a warning, on the way.
+        configSharingService.setProperty("publicCorsAllowedOrigins", publicCorsAllowedOrigins);
+        configSharingService.setProperty("publicCorsMatchReferer", publicCorsMatchReferer);
+        configSharingService.setProperty("profileIdCookieSameSite", contextserverProfileIdCookieSameSite);
         configSharingService.setProperty("publicPostRequestBytesLimit", publicPostRequestBytesLimit);
         configSharingService.setProperty("allowedProfileDownloadFormats", allowedProfileDownloadFormats);
         LOGGER.info("WebConfig activated.");
@@ -102,10 +125,6 @@ public class WebConfig {
         return contextserverProfileIdCookieMaxAgeInSeconds;
     }
 
-    public boolean isContextserverProfileIdCookieHttpOnly() {
-        return contextserverProfileIdCookieHttpOnly;
-    }
-
     public String getAllowedProfileDownloadFormats() {
         return allowedProfileDownloadFormats;
     }
diff --git a/web-servlets/src/main/resources/org.apache.unomi.web.cfg b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
index ad620bc11..675a4b6e8 100644
--- a/web-servlets/src/main/resources/org.apache.unomi.web.cfg
+++ b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
@@ -22,8 +22,18 @@ contextserver.domain=${org.apache.unomi.profile.cookie.domain:-}
 contextserver.profileIdCookieName=${org.apache.unomi.profile.cookie.name:-context-profile-id}
 # This setting controls the maximum age of the profile cookie. By default it is set to a year.
 contextserver.profileIdCookieMaxAgeInSeconds=${org.apache.unomi.profile.cookie.maxAgeInSeconds:-31536000}
-# This setting controls if the cookie should be flagged as HttpOnly or not.
+# This setting controls if the cookie should be flagged as HttpOnly or not. Only false turns it off; any other
+# value keeps it on and is logged as a warning.
 contextserver.profileIdCookieHttpOnly=${org.apache.unomi.profile.cookie.httpOnly:-true}
+# SameSite for the visitor cookie. Lax is the default. None is used only when the cookie is also Secure
+# and public.cors.allowedOrigins lists at least one origin; otherwise it falls back to Lax.
+contextserver.profileIdCookieSameSite=${org.apache.unomi.profile.cookie.sameSite:-Lax}
+# Comma-separated website origins (scheme, host and port) that may send the visitor cookie
+# to context.js, context.json, eventcollector and client. Empty means no origin other than Unomi's own may send cookies.
+public.cors.allowedOrigins=${org.apache.unomi.public.cors.allowedOrigins:-}
+# Let a listed website that embeds context.js with a <script> tag (no Origin header) be recognised by its
+# Referer. Off by default: it also trusts anything that website frames, such as sandboxed ads.
+public.cors.matchReferer=${org.apache.unomi.public.cors.matchReferer:-false}
 #Allowed profile download formats, actually only csv (horizontal and vertical), json, text and yaml are allowed.
 allowed.profile.download.formats=${org.apache.unomi.profile.download.formats:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking that the requests do not exceed the limit.
diff --git a/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java b/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java
new file mode 100644
index 000000000..f243ca8b3
--- /dev/null
+++ b/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.web.servlets;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+
+import java.lang.reflect.Field;
+
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class WebConfigTest {
+
+    @Test
+    void cookieFlagsArePublishedAsTheConfiguredText() throws Exception {
+        // As booleans, the framework would turn "flase" into false before Unomi could keep HttpOnly on.
+        WebConfig.Config config = mock(WebConfig.Config.class);
+        when(config.contextserver_profileIdCookieHttpOnly()).thenReturn("flase");
+        when(config.public_cors_matchReferer()).thenReturn("yes");
+        when(config.public_cors_allowedOrigins()).thenReturn("https://shop.example");
+        when(config.contextserver_profileIdCookieSameSite()).thenReturn("None");
+        ConfigSharingService configSharingService = mock(ConfigSharingService.class);
+
+        WebConfig webConfig = new WebConfig();
+        Field field = WebConfig.class.getDeclaredField("configSharingService");
+        field.setAccessible(true);
+        field.set(webConfig, configSharingService);
+        webConfig.activate(config);
+
+        verify(configSharingService).setProperty("profileIdCookieHttpOnly", "flase");
+        verify(configSharingService).setProperty("publicCorsMatchReferer", "yes");
+        // Origins before SameSite, so SameSite=None is never resolved against an empty list on the way.
+        InOrder order = inOrder(configSharingService);
+        order.verify(configSharingService).setProperty("publicCorsAllowedOrigins", "https://shop.example");
+        order.verify(configSharingService).setProperty("profileIdCookieSameSite", "None");
+    }
+}