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

asf-gitbox-commits pushed a commit to branch UNOMI-978-action-identity-ownership
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 81504c79102de9b91c4ba0242a85a426730732b5
Author: Serge Huber <[email protected]>
AuthorDate: Thu Aug 13 14:26:34 2026 +0200

    UNOMI-978: Gate cross-profile merge and systemProperties writes on a 
trusted caller
    
    Two built-in actions operate on identity rather than on the calling 
profile's own data.
    MergeProfilesOnPropertyAction merges the current profile into another one 
selected by a property
    value, and UpdatePropertiesAction can write systemProperties, where 
trust-bearing state such as
    merge and identity markers is kept. Deciding that two profiles are the same 
person, or writing the
    markers that record it, is a claim about identity, and a claim about 
identity should come from a
    caller the server has established as entitled to make it.
    
    Both now require a trusted caller, behind a single 
isTrustedIdentityCaller() seam. Writes to a
    caller's own properties and same-profile merges are unchanged, and a 
server-side integration
    holding the tenant private key keeps both capabilities.
    
    A refused attempt is logged through the shared 
org.apache.unomi.api.utils.LogSanitizer, so a
    request-derived value cannot break out of its log record.
    
    The login sample is rewritten to demonstrate the pattern this leaves in 
place: the browser posts to
    the operator's own servlet, which holds the tenant private key and performs 
the merge server-side,
    rather than asking the visitor's browser to assert who it is. Integrations 
that performed the merge
    from the browser need to move that step behind their own server, which the 
sample now shows end to
    end.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../test/java/org/apache/unomi/itests/BasicIT.java |  29 +-
 .../org/apache/unomi/itests/ContextServletIT.java  | 209 +++++++++++
 .../org/apache/unomi/itests/ProfileMergeIT.java    |  65 ++++
 .../unomi/itests/PropertiesUpdateActionIT.java     |  76 ++++
 manual/src/main/asciidoc/samples/login-sample.adoc | 203 +++++++++--
 .../actions/MergeProfilesOnPropertyAction.java     |  32 ++
 .../baseplugin/actions/UpdatePropertiesAction.java |  71 +++-
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   1 +
 .../actions/MergeProfilesOnPropertyActionTest.java | 136 +++++++
 .../actions/UpdatePropertiesActionTest.java        | 193 ++++++++++
 samples/login-integration/pom.xml                  |  44 ++-
 samples/login-integration/setup.sh                 | 299 ++++++++++++++++
 .../unomi/samples/login/LoginSampleResources.java  |  37 ++
 .../apache/unomi/samples/login/LoginServlet.java   | 395 +++++++++++++++++++++
 .../src/main/resources/static/index.html           |  75 ++++
 .../resources/static/javascript/login-example.js   |  58 +++
 .../src/main/webapp/WEB-INF/web.xml                |  24 --
 .../login-integration/src/main/webapp/index.html   |  70 ----
 .../src/main/webapp/javascript/login-example.js    | 139 --------
 .../unomi/samples/login/LoginServletTest.java      | 353 ++++++++++++++++++
 20 files changed, 2233 insertions(+), 276 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java 
b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
index bd650359b..ff4e12f06 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
@@ -38,6 +38,8 @@ import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.ops4j.pax.exam.junit.PaxExam;
+
+import java.util.Base64;
 import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
 import org.ops4j.pax.exam.spi.reactors.PerSuite;
 import org.slf4j.Logger;
@@ -248,15 +250,36 @@ public class BasicIT extends BaseIT {
         loginEventPropertiesVisitor2.put(LAST_NAME, LAST_NAME_VISITOR_2);
         loginEventPropertiesVisitor2.put(EMAIL, EMAIL_VISITOR_2);
 
-        // Create login event with VISITOR_2
         ContextRequest contextRequestLoginVisitor2 = 
getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor2,
                 EMAIL_VISITOR_2, SESSION_ID_4);
+
+        // Public API key must not switch identity / merge into another 
profile on a shared cookie.
+        HttpPost publicSwitchAttempt = new 
HttpPost(getFullUrl("/cxs/context.json"));
+        publicSwitchAttempt.addHeader("Cookie", 
requestResponsePageView1.getCookieHeaderValue());
+        publicSwitchAttempt.addHeader("X-Unomi-Api-Key", testPublicKeyValue);
+        publicSwitchAttempt.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2),
+                ContentType.create("application/json")));
+        TestUtils.RequestResponse publicSwitchResponse = 
executeContextJSONRequest(publicSwitchAttempt, SESSION_ID_4);
+        Assert.assertEquals("Public login must not switch away from the cookie 
profile",
+                profileIdVisitor1, 
publicSwitchResponse.getContextResponse().getProfileId());
+
+        // Public login still runs copyProperties on the cookie profile; 
restore visitor1 before the trusted switch.
+        Profile restoredVisitor1 = profileService.load(profileIdVisitor1);
+        restoredVisitor1.setProperty(FIRST_NAME, FIRST_NAME_VISITOR_1);
+        restoredVisitor1.setProperty(LAST_NAME, LAST_NAME_VISITOR_1);
+        restoredVisitor1.setProperty(EMAIL, EMAIL_VISITOR_1);
+        profileService.save(restoredVisitor1);
+        keepTrying("Visitor1 properties not restored", () -> 
profileService.load(profileIdVisitor1),
+                p -> FIRST_NAME_VISITOR_1.equals(p.getProperty(FIRST_NAME)), 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Trusted private key may switch the browsing profile to VISITOR_2.
         HttpPost requestLoginVisitor2 = new 
HttpPost(getFullUrl("/cxs/context.json"));
         requestLoginVisitor2.addHeader("Cookie", 
requestResponsePageView1.getCookieHeaderValue());
-        requestLoginVisitor2.addHeader("X-Unomi-Api-Key", testPublicKeyValue);
+        requestLoginVisitor2.setHeader("Authorization", "Basic " + 
Base64.getEncoder().encodeToString(
+                (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes()));
         requestLoginVisitor2.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2),
                 ContentType.create("application/json")));
-        TestUtils.RequestResponse requestResponseLoginVisitor2 = 
executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4);
+        TestUtils.RequestResponse requestResponseLoginVisitor2 = 
executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4, -1, false);
         // We should have a new profile id so the session should have been 
moved from VISITOR_1 to VISITOR_2
         String profileIdVisitor2 = 
requestResponseLoginVisitor2.getContextResponse().getProfileId();
         Assert.assertNotEquals("Context profile id should not be the same", 
profileIdVisitor1,
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index 36ce3393d..247460e26 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -441,6 +441,214 @@ public class ContextServletIT extends BaseIT {
                 DEFAULT_SHOULDBETRUE_TRIES);
     }
 
+
+
+
+
+
+
+    @Test
+    public void testPublicHttp_updateProperties_cannotUpdateAnotherProfile() 
throws Exception {
+        String otherId = "update-other-" + System.currentTimeMillis();
+        Profile other = new Profile(otherId);
+        profileService.save(other);
+        keepTrying("Other profile not found", () -> 
profileService.load(otherId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "update-public-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+
+        Event updateEvent = new Event();
+        updateEvent.setEventType("updateProperties");
+        updateEvent.setScope(TEST_SCOPE);
+        Map<String, Object> props = new HashMap<>();
+        props.put("targetId", otherId);
+        props.put("targetType", "profile");
+        Map<String, Object> toUpdate = new HashMap<>();
+        toUpdate.put("properties.firstName", "CHANGED");
+        props.put("update", toUpdate);
+        updateEvent.setProperties(props);
+
+        ContextRequest probe = new ContextRequest();
+        probe.setSessionId(sessionId);
+        probe.setEvents(Collections.singletonList(updateEvent));
+        HttpPost probeRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(probeRequest);
+        probeRequest.addHeader("Cookie", established.getCookieHeaderValue());
+        probeRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(probe), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(probeRequest, sessionId);
+
+        shouldBeTrueUntilEnd("Other profile must not be updated by public 
updateProperties",
+                () -> profileService.load(otherId),
+                p -> p.getProperty("firstName") == null,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+    }
+
+    @Test
+    public void testPrivateKeyHttp_updateProperties_canUpdateAnotherProfile() 
throws Exception {
+        String otherId = "trusted-update-other-" + System.currentTimeMillis();
+        Profile other = new Profile(otherId);
+        profileService.save(other);
+        keepTrying("Other profile not found", () -> 
profileService.load(otherId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "trusted-update-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+
+        Event updateEvent = new Event();
+        updateEvent.setEventType("updateProperties");
+        updateEvent.setScope(TEST_SCOPE);
+        Map<String, Object> props = new HashMap<>();
+        props.put("targetId", otherId);
+        props.put("targetType", "profile");
+        Map<String, Object> toUpdate = new HashMap<>();
+        toUpdate.put("properties.firstName", "TRUSTED_HTTP");
+        props.put("update", toUpdate);
+        updateEvent.setProperties(props);
+
+        ContextRequest update = new ContextRequest();
+        update.setSessionId(sessionId);
+        update.setEvents(Collections.singletonList(updateEvent));
+        HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+        trusted.addHeader("Cookie", established.getCookieHeaderValue());
+        trusted.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(update), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(trusted, sessionId, -1, false);
+
+        waitForProfileProperty(otherId, "firstName", "TRUSTED_HTTP");
+    }
+
+    @Test
+    public void testPublicHttpLogin_cannotMergeIntoExistingOtherProfile() 
throws Exception {
+        ConditionType conditionType = getObjectMapper().readValue(
+                new 
File("data/tmp/testLoginEventCondition.json").toURI().toURL(), 
ConditionType.class);
+        definitionsService.setConditionType(conditionType);
+        keepTrying("loginEventCondition not registered",
+                () -> 
definitionsService.getConditionType("loginEventCondition"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+        Rule rule = getObjectMapper().readValue(new 
File("data/tmp/testLogin.json").toURI().toURL(), Rule.class);
+        createAndWaitForRule(rule);
+
+        String otherEmail = "other-takeover-" + System.currentTimeMillis() + 
"@example.com";
+        String otherId = "other-merge-" + System.currentTimeMillis();
+        Profile other = new Profile(otherId);
+        other.setProperty("email", otherEmail);
+        other.setSystemProperty("mergeIdentifier", otherEmail);
+        profileService.save(other);
+        keepTrying("Other not found", () -> profileService.load(otherId), 
Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "public-merge-session-" + 
System.currentTimeMillis();
+        ContextRequest pageView = new ContextRequest();
+        pageView.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(pageView), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        String publicCallerId = 
established.getContextResponse().getProfileId();
+        assertNotEquals(otherId, publicCallerId);
+
+        CustomItem loginTarget = new CustomItem(otherEmail, "visitor");
+        Map<String, Object> loginProps = new HashMap<>();
+        loginProps.put("email", otherEmail);
+        loginTarget.setProperties(loginProps);
+        Event login = new Event();
+        login.setEventType("login");
+        login.setScope(TEST_SCOPE);
+        login.setTarget(loginTarget);
+        login.setTimeStamp(new Date());
+
+        ContextRequest loginRequest = new ContextRequest();
+        loginRequest.setSessionId(sessionId);
+        loginRequest.setEvents(Collections.singletonList(login));
+        HttpPost probe = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(probe);
+        probe.addHeader("Cookie", established.getCookieHeaderValue());
+        probe.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(loginRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse afterLogin = executeContextJSONRequest(probe, 
sessionId);
+
+        assertEquals("Public login must not take over the other profile",
+                publicCallerId, 
afterLogin.getContextResponse().getProfileId());
+        assertNotNull(profileService.load(otherId));
+        rulesService.removeRule("testLogin");
+    }
+
+    /**
+     * Counterpart to {@link 
#testPublicHttpLogin_cannotMergeIntoExistingOtherProfile()}: the merge
+     * must still work end to end for a trusted caller, over real HTTP through 
the auth filter and
+     * the rules engine, not just when the subject is set programmatically.
+     */
+    @Test
+    public void testPrivateKeyHttpLogin_canMergeIntoExistingProfile() throws 
Exception {
+        ConditionType conditionType = getObjectMapper().readValue(
+                new 
File("data/tmp/testLoginEventCondition.json").toURI().toURL(), 
ConditionType.class);
+        definitionsService.setConditionType(conditionType);
+        keepTrying("loginEventCondition not registered",
+                () -> 
definitionsService.getConditionType("loginEventCondition"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+        Rule rule = getObjectMapper().readValue(new 
File("data/tmp/testLogin.json").toURI().toURL(), Rule.class);
+        createAndWaitForRule(rule);
+
+        String knownEmail = "trusted-merge-" + System.currentTimeMillis() + 
"@example.com";
+        String knownProfileId = "trusted-merge-known-" + 
System.currentTimeMillis();
+        Profile known = new Profile(knownProfileId);
+        known.setProperty("email", knownEmail);
+        known.setSystemProperty("mergeIdentifier", knownEmail);
+        profileService.save(known);
+        keepTrying("Known profile not found", () -> 
profileService.load(knownProfileId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Anonymous browsing first, exactly as a visitor would before logging 
in.
+        String sessionId = "trusted-merge-session-" + 
System.currentTimeMillis();
+        ContextRequest pageView = new ContextRequest();
+        pageView.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(pageView), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        String anonymousId = established.getContextResponse().getProfileId();
+        assertNotEquals(knownProfileId, anonymousId);
+
+        // The login event is then emitted by a trusted server-side caller 
after authentication.
+        CustomItem loginTarget = new CustomItem(knownEmail, "visitor");
+        Map<String, Object> loginProps = new HashMap<>();
+        loginProps.put("email", knownEmail);
+        loginTarget.setProperties(loginProps);
+        Event login = new Event();
+        login.setEventType("login");
+        login.setScope(TEST_SCOPE);
+        login.setTarget(loginTarget);
+        login.setTimeStamp(new Date());
+
+        ContextRequest loginRequest = new ContextRequest();
+        loginRequest.setSessionId(sessionId);
+        loginRequest.setEvents(Collections.singletonList(login));
+        HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+        trusted.addHeader("Cookie", established.getCookieHeaderValue());
+        trusted.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(loginRequest), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(trusted, sessionId, -1, false);
+
+        keepTrying("Trusted login should merge the anonymous profile into the 
known one",
+                () -> profileService.load(anonymousId),
+                p -> p != null && knownEmail.equals(p.getProperty("email")),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        rulesService.removeRule("testLogin");
+    }
+
+
+
     @Test
     public void testPublicCaller_mismatchedBodyProfileId_ignored() throws 
Exception {
         String sessionId = "mismatch-session-" + System.currentTimeMillis();
@@ -782,6 +990,7 @@ public class ContextServletIT extends BaseIT {
                 Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
     }
 
+
     @Test
     public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws 
Exception {
         String otherId = "body-only-other-" + System.currentTimeMillis();
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
index 20011d21c..68853c2b4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
@@ -176,6 +176,71 @@ public class ProfileMergeIT extends BaseIT {
      * - a new one, if it's the first time we encounter his own mergeIdentifier
      * - a previous one, if we already have a profile in DB with the same 
mergeIdentifier. (TESTED in this scenario)
      */
+    /**
+     * Public / publicCallers must not merge into an existing other profile 
(identity takeover).
+     * Suite {@code @Before} installs a tenant-admin subject; this test 
temporarily downgrades it.
+     */
+    @Test
+    public void testUntrustedCaller_cannotMergeIntoExistingOtherProfile() 
throws InterruptedException {
+        createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+        Profile other = new Profile("otherProfileID");
+        other.setProperty("email", "[email protected]");
+        other.setSystemProperty("mergeIdentifier", "[email protected]");
+        profileService.save(other);
+
+        keepTrying("Other profile not found", () -> 
profileService.load("otherProfileID"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        Profile publicCaller = new Profile("publicCallerProfileID");
+        publicCaller.setProperty("email", "[email protected]");
+        Session session = new Session("untrustedMergeSession", publicCaller, 
new Date(), null);
+        Event event = new Event(TEST_EVENT_TYPE, session, publicCaller, null, 
null, publicCaller, new Date());
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            eventService.send(event);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        Assert.assertEquals("publicCallerProfileID", 
event.getProfile().getItemId());
+        Assert.assertEquals("publicCallerProfileID", 
event.getSession().getProfile().getItemId());
+        Assert.assertNotNull(profileService.load("otherProfileID"));
+    }
+
+    @Test
+    public void testTrustedPrivateKeySubject_canMergeIntoExistingProfile() 
throws InterruptedException {
+        createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+        Profile other = new Profile("trustedOtherProfileID");
+        other.setProperty("email", "[email protected]");
+        other.setSystemProperty("mergeIdentifier", 
"[email protected]");
+        other.setProperty("firstVisit", new Date(0));
+        profileService.save(other);
+
+        keepTrying("Other profile not found", () -> 
profileService.load("trustedOtherProfileID"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        Profile caller = new Profile("trustedCallerProfileID");
+        caller.setProperty("email", "[email protected]");
+        caller.setProperty("firstVisit", new Date());
+        Session session = new Session("trustedMergeSession", caller, new 
Date(), null);
+        Event event = new Event(TEST_EVENT_TYPE, session, caller, null, null, 
caller, new Date());
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
true));
+            eventService.send(event);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        Assert.assertEquals("trustedOtherProfileID", 
event.getProfile().getItemId());
+        Assert.assertEquals("trustedOtherProfileID", 
event.getSession().getProfile().getItemId());
+    }
+
     @Test
     public void 
testProfileMergeOnPropertyAction_sessionReassigned_existingProfile() throws 
InterruptedException {
         // create rule
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java 
b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
index ad2206a6b..319fabc0f 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -20,6 +20,7 @@ package org.apache.unomi.itests;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.plugins.baseplugin.actions.UpdatePropertiesAction;
 import org.junit.Assert;
 import org.junit.Before;
@@ -118,6 +119,81 @@ public class PropertiesUpdateActionIT extends BaseIT {
         waitForProfileProperty(PROFILE_TEST_ID, "firstName", "UPDATED FIRST 
NAME");
     }
 
+    @Test
+    public void testUntrustedCaller_cannotUpdateAnotherProfile() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+        Profile other = profileService.load(PROFILE_TEST_ID);
+        Assert.assertNull(other.getProperty("firstName"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("properties.firstName", "SHOULD_NOT_APPLY");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, 
PROFILE_TEST_ID);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, 
"profile");
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            int changes = eventService.send(updateProperties);
+            Assert.assertEquals(EventService.NO_CHANGE, changes);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        shouldBeTrueUntilEnd("Other profile must remain unchanged",
+                () -> profileService.load(PROFILE_TEST_ID),
+                p -> p.getProperty("firstName") == null,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+    }
+
+    @Test
+    public void testUntrustedCaller_cannotWriteSystemProperties() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TEST_ID);
+        Assert.assertNull(caller.getSystemProperties().get("mergeIdentifier"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("systemProperties.mergeIdentifier", "reused");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            eventService.send(updateProperties);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier"));
+    }
+
+    @Test
+    public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+        
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getProperty("firstName"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("properties.firstName", "TRUSTED UPDATE");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, 
PROFILE_TEST_ID);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, 
"profile");
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
true));
+            eventService.send(updateProperties);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE");
+    }
+
     @Test
     public void testUpdateProperties_CurrentProfile_PROPS_TO_ADD() throws 
InterruptedException {
         Profile profile = profileService.load(PROFILE_TEST_ID);
diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc 
b/manual/src/main/asciidoc/samples/login-sample.adoc
index dc8a88d20..8e2ae12fb 100644
--- a/manual/src/main/asciidoc/samples/login-sample.adoc
+++ b/manual/src/main/asciidoc/samples/login-sample.adoc
@@ -14,51 +14,206 @@
 [#_login_sample]
 === Login sample
 
-This sample is an example of what is involved in integrating a login with 
Apache Unomi.
+This sample shows how to integrate a **server-side** login with Apache Unomi 
so that
+`mergeProfilesOnPropertyAction` can merge profiles on email (or another merge 
key).
 
-==== Warning !
+==== Why server-side?
 
-The example code uses client-side Javascript code to send the login event. 
This is only
-done this way for the sake of samples simplicity but if should NEVER BE DONE 
THIS WAY in real cases.
+Under Unomi 3.1 hardening 
(https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] /
+<<_client_facing_hardening_3_1,client-facing hardening>>), cross-profile merge 
requires a
+**trusted** caller (system administrator or tenant private key). A browser 
call with only a
+public API key is not trusted — merge is refused.
 
-The login event should always be sent from the server performing the actual 
login since it must
-only be sent if the user has authenticated properly, and only the 
authentication server can validate this.
+A real authentication server must validate the password, then call Unomi. This 
sample mimics
+that with a small DS servlet inside Unomi (`/login/authenticate`).
 
-==== Installing the samples
+==== How it works
 
-Login into the Unomi Karaf SSH shell using something like this :
+. Open http://localhost:8181/login/index.html and submit the form
+   (the password you set as `demoPassword` in the sample configuration).
+. The page posts to `/login/authenticate` (same host) — it never posts a login 
event to `/cxs/context.json` from JavaScript.
+. `LoginServlet` checks the demo password, then `POST`s a `login` event to 
`/cxs/context.json` using Basic auth
+   (`tenantId:privateKey` — a tenant private key is the only credential the 
sample accepts).
+. The session id is generated by the servlet and kept on the browser's own 
`HttpSession` — it is
+   deliberately *not* read from the form. See 
<<_never_forward_client_supplied_identifiers,Never forward client-supplied 
identifiers>>.
+. The bundled rule `exampleLogin` (`META-INF/cxs/rules/exampleLogin.json`) runs
+   `mergeProfilesOnPropertyAction` on `target.properties.email` plus 
`copyPropertiesAction`.
 
-[source]
+[plantuml]
 ----
-ssh -p 8102 karaf@localhost (the password is the one you set in 
UNOMI_ROOT_PASSWORD; no default is shipped)
+@startuml
+title Login sample — trusted server-side merge (UNOMI-972)
+
+actor Browser
+participant "Login page\n/login/index.html" as Page
+participant "LoginServlet\n/login/authenticate" as Auth
+participant "Unomi REST\n/cxs/context.json" as Context
+participant "exampleLogin rule" as Rule
+
+Browser -> Page: Open /login/index.html
+Browser -> Page: Submit form (email, password, …)
+Page -> Auth: POST /login/authenticate\n(form fields only)
+
+alt Wrong demo password
+    Auth --> Page: 401 { error }
+    Page --> Browser: Show error
+else Password OK
+    Auth -> Auth: Resolve sessionId from HttpSession\n(never from the request)
+    Auth -> Auth: Build login ContextRequest\n(scope from config)
+    Auth -> Context: POST /cxs/context.json?sessionId=…\nBasic 
tenantId:privateKey
+    activate Context
+    Context -> Rule: login event
+    Rule -> Rule: mergeProfilesOnPropertyAction\n(on email) + 
copyPropertiesAction
+    Context --> Auth: context JSON + Set-Cookie
+    deactivate Context
+    Auth --> Page: Forward Unomi response\n(+ profile cookie)
+    Page --> Browser: Show profileId
+end
+
+note over Page
+  Browser never calls /cxs/context.json
+  for login — only the trusted servlet does.
+end note
+
+note over Context
+  Public API key alone is not trusted;
+  merge would be refused.
+end note
+
+@enduml
 ----
 
-Install the login samples using the following command:
+Source: `samples/login-integration/` (OSGi Declarative Services, no Blueprint).
 
-[source]
+[[_never_forward_client_supplied_identifiers]]
+==== Never forward client-supplied identifiers
+
+Moving the login event server-side is only half of the fix. A trusted caller 
is allowed to adopt
+whatever profile owns the `sessionId` it passes, and to merge profiles on 
whatever identifier the
+event carries. A proxy that takes those values from its own untrusted callers 
and replays them under
+trusted credentials hands that power straight back to the browser: anyone who 
guesses another
+visitor's session id could rebind or merge that visitor's profile.
+
+`LoginServlet` therefore derives the session id from state it controls — a 
UUID stored on the
+browser's container `HttpSession` — and never reads it from a request 
parameter. Apply the same rule
+in your own integration:
+
+* Derive the `sessionId` from your authenticated server-side session, not from 
the request body.
+* Derive the merge identifier (here, the email) from the account you just 
authenticated, not from a
+  form field the caller chose.
+* Treat every other Unomi identifier the same way: if the value came from the 
caller, it must not be
+  forwarded under credentials the caller does not hold.
+
+NOTE: The sample's same-origin check is a lightweight stand-in for CSRF 
protection, and
+`demoPassword` is a single shared secret standing in for a user directory. A 
real integration should
+use a per-session CSRF token and authenticate each user against your identity 
provider. The sample
+ships no default `demoPassword` for the same reason Unomi ships no default 
admin password: a
+credential published in source is a credential everyone has.
+
+==== Build
+
+From the Unomi source tree:
+
+[source,bash]
 ----
-bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+mvn -pl samples/login-integration -am install -DskipTests
 ----
 
-when the bundle is successfully install you will get an bundle ID back we will 
call it BUNDLE_ID. 
+==== Set up
 
-You can then do:
+Build the sample, then run the setup script on the Unomi host. It creates the 
tenant and scope,
+issues a tenant private key, generates a demo password, configures the bundle 
and starts it:
 
-[source]
+[source,bash]
 ----
-bundle:start BUNDLE_ID
+mvn -pl samples/login-integration -am install -DskipTests
+
+export UNOMI_ROOT_PASSWORD='your-admin-password'
+./samples/login-integration/setup.sh
 ----
 
-If all went well you can access the login samples HTML page here :
+`KARAF_HOME` must point at the Unomi install that is **actually running** and 
serving `UNOMI_URL` —
+the script writes into that install's `etc/` and `deploy/` directories, so 
configuring a different
+copy would have no effect. Set it explicitly unless you have exactly one built 
distribution in the
+source tree:
 
-[source]
+[source,bash]
 ----
-http://localhost:8181/login/index.html
+cd samples/login-integration
+KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh
 ----
 
-You can fill in the form to test it. Note that the hardcoded password is:
+The script validates this before changing anything, and refuses to continue if 
the directory does not
+exist, does not look like a Unomi install, is not writable, or belongs to an 
instance that is stopped
+(it checks `karaf.pid` against the running process, so a stale pid file from a 
previous run is caught
+too). If you have more than one built distribution under `package/target`, it 
will not guess — set
+`KARAF_HOME`.
+
+The script waits until the sample page answers before reporting success, then 
prints the page URL and
+the **generated demo password** — copy it, it is not stored anywhere you can 
read it back. The private
+key is never printed. Re-running is safe: it reuses an existing tenant and 
scope and issues a fresh key.
+
+It needs `curl` and `jq`, and filesystem access to the Unomi install — it 
writes
+`etc/org.apache.unomi.samples.login.cfg` (mode `600`, since it holds the 
private key) and copies the
+bundle into `deploy/`, both of which Karaf picks up within a second. 
`KARAF_HOME` is auto-detected in
+the source tree; set it otherwise. No Karaf console, SSH or console credential 
is involved. Override
+`UNOMI_URL`, `UNOMI_TENANT_ID`, `UNOMI_SCOPE` or `DEMO_PASSWORD` if the 
defaults do not suit;
+`./setup.sh --help` lists them.
+
+NOTE: The script uses the system administrator credential because creating a 
tenant is an operator
+action. The servlet never sees it — it receives only the scoped tenant private 
key the script issues.
+A tenant private key is the **only** credential the sample accepts: a system 
administrator password
+would also satisfy the merge gate, but it grants far more than this sample 
needs and is not scoped to
+a single tenant, so the servlet deliberately refuses to use one.
+
+===== Setting it up by hand
+
+If you would rather not run the script, the equivalent steps are: create the 
tenant and a scope,
+`POST /cxs/tenants/<tenant>/apikeys?type=PRIVATE` and keep the returned 
`plainTextKey` (it is shown
+once), then from the Karaf console:
 
-[source]
+[source,bash]
 ----
-test1234
-----
\ No newline at end of file
+bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+config:edit org.apache.unomi.samples.login
+config:property-set tenantId default
+config:property-set scope default
+config:property-set privateKey <plainTextKey>
+config:property-set demoPassword <choose-a-password>
+config:update
+bundle:start <bundle-id>
+----
+
+Either way, the bundle reports on activation whether it is usable. A 
configured sample logs, at
+`INFO`:
+
+----
+Login sample ready - open http://localhost:8181/login/index.html 
(tenantId=default, scope=default)
+----
+
+A sample still missing something logs a `WARN` naming exactly what to set. 
Correct it and run
+`config:update` again — the servlet re-reads its configuration and reprints 
the status without a
+restart.
+
+Ensure the tenant allows login events from the servlet's source IP (typically 
`127.0.0.1` when
+calling localhost) via tenant authorized IPs.
+
+==== Test profile merge
+
+. Open http://localhost:8181/login/index.html
+. Log in with email `[email protected]` and your configured `demoPassword`. 
Note the `profileId` in the success message.
+. Clear the `context-profile-id` cookie for `localhost` (or use a private 
browser window) so Unomi would otherwise create a new anonymous profile.
+. Log in again with the **same** email. Expect the **same** master `profileId` 
(merge on email).
+. Optional: log in with a different email — expect a different profile.
+
+If you see `Unable to resolve a tenant`, create the tenant and ensure 
`tenantId` matches the tenant
+the private key belongs to — the key's tenant is what Unomi authenticates 
against.
+If schema validation rejects the scope, create the scope (see above).
+If you see a configuration error from `/login/authenticate`, the servlet has 
no trusted credentials
+yet — set `privateKey` as shown above and run `config:update`.
+
+==== Related
+
+* <<_client_facing_hardening_3_1,Client-facing hardening (3.1)>>
+* <<_how_profile_tracking_works,How profile tracking works>>
+* Rule JSON: 
https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json
diff --git 
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
 
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
index a29b39f30..ff4832cc3 100644
--- 
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
+++ 
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
@@ -26,6 +26,7 @@ import org.apache.unomi.api.actions.Action;
 import org.apache.unomi.api.actions.ActionExecutor;
 import org.apache.unomi.api.conditions.Condition;
 import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.*;
 import org.apache.unomi.persistence.spi.PersistenceService;
 import org.slf4j.Logger;
@@ -91,6 +92,13 @@ public class MergeProfilesOnPropertyAction implements 
ActionExecutor {
 
             // Check if the user switched to another profile
             if (StringUtils.isNotEmpty(currentProfileMergeValue) && 
!currentProfileMergeValue.equals(mergePropValue)) {
+                if (!isTrustedIdentityCaller()) {
+                    LOGGER.warn("Refusing profile merge switch for untrusted 
caller (mergeProp={})", mergePropName);
+                    if (tracer != null) {
+                        tracer.endOperation(false, "Untrusted caller cannot 
switch merge identity");
+                    }
+                    return EventService.NO_CHANGE;
+                }
                 if (tracer != null) {
                     tracer.trace("Profile switch detected", Map.of(
                         "fromValue", currentProfileMergeValue,
@@ -119,6 +127,18 @@ public class MergeProfilesOnPropertyAction implements 
ActionExecutor {
                 return profileUpdated ? EventService.PROFILE_UPDATED : 
EventService.NO_CHANGE;
             }
 
+            // Merging into another existing profile rebinds the session — 
require a caller holding
+            // system access (see isTrustedIdentityCaller), not a 
public/unauthenticated event.
+            if (!isTrustedIdentityCaller()) {
+                LOGGER.warn("Refusing profile merge for untrusted caller 
(mergeProp={}, candidates={})",
+                        mergePropName, profilesToBeMerge.size());
+                if (tracer != null) {
+                    tracer.endOperation(false, "Untrusted caller cannot merge 
into another profile");
+                }
+                // Keep only the merge identifier write on the current profile 
when it was empty
+                return profileUpdated ? EventService.PROFILE_UPDATED : 
EventService.NO_CHANGE;
+            }
+
             // add current Profile to profiles to be merged
             if (profilesToBeMerge.stream().noneMatch(p -> 
StringUtils.equals(p.getItemId(), eventProfile.getItemId()))) {
                 profilesToBeMerge.add(eventProfile);
@@ -327,6 +347,18 @@ public class MergeProfilesOnPropertyAction implements 
ActionExecutor {
         }
     }
 
+    /**
+     * Whether the caller holds system access, i.e. the administrator or 
tenant administrator role.
+     * <p>
+     * This is a role check, not a check of the credential that produced it: a 
tenant private key
+     * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore 
passes, while a tenant
+     * public API key or an unauthenticated context event does not. Identity 
merges rebind sessions,
+     * so they are restricted to callers that hold that role.
+     */
+    private boolean isTrustedIdentityCaller() {
+        return securityService != null && securityService.hasSystemAccess();
+    }
+
     public void setProfileService(ProfileService profileService) {
         this.profileService = profileService;
     }
diff --git 
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
 
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
index af9a2a091..78070d144 100644
--- 
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
+++ 
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
@@ -24,6 +24,9 @@ import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.PropertyType;
 import org.apache.unomi.api.actions.Action;
 import org.apache.unomi.api.actions.ActionExecutor;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.utils.LogSanitizer;
+import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.api.services.ProfileService;
 import org.apache.unomi.persistence.spi.PropertyHelper;
@@ -46,11 +49,17 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
 
     public static final String TARGET_TYPE_PROFILE = "profile";
 
+    /** The reserved profile field that only a caller with system access may 
write. */
+    private static final String SYSTEM_PROPERTIES_KEY = "systemProperties";
+    /** Prefix of the profile properties that only a caller with system access 
may write. */
+    private static final String SYSTEM_PROPERTIES_PREFIX = 
SYSTEM_PROPERTIES_KEY + ".";
+
     private static final Logger LOGGER = 
LoggerFactory.getLogger(UpdatePropertiesAction.class.getName());
 
     private ProfileService profileService;
     private EventService eventService;
     private TracerService tracerService;
+    private SecurityService securityService;
 
     public int execute(Action action, Event event) {
         RequestTracer tracer = null;
@@ -64,6 +73,8 @@ public class UpdatePropertiesAction implements ActionExecutor 
{
             Profile target = event.getProfile();
             String targetId = (String) event.getProperty(TARGET_ID_KEY);
             String targetType = (String) event.getProperty(TARGET_TYPE_KEY);
+            // Resolved once for the whole action: the caller's roles cannot 
change while it runs.
+            final boolean trustedCaller = isTrustedIdentityCaller();
 
             if (tracer != null) {
                 Map<String, Object> traceData = new HashMap<>();
@@ -74,12 +85,20 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
             }
 
             if (StringUtils.isNotBlank(targetId) && event.getProfile() != null 
&& !targetId.equals(event.getProfile().getItemId())) {
+                if (!trustedCaller) {
+                    LOGGER.warn("Refusing cross-profile property update for 
untrusted caller (targetId={})",
+                            LogSanitizer.forLogging(targetId));
+                    if (tracer != null) {
+                        tracer.endOperation(false, "Untrusted caller cannot 
update another profile");
+                    }
+                    return EventService.NO_CHANGE;
+                }
                 target = TARGET_TYPE_PROFILE.equals(targetType) ? 
profileService.load(targetId) : profileService.loadPersona(targetId);
                 if (target == null) {
                     if (tracer != null) {
                         tracer.endOperation(false, "No profile found with Id: 
" + targetId);
                     }
-                    LOGGER.warn("No profile found with Id : {}. Update 
skipped.", targetId);
+                    LOGGER.warn("No profile found with Id : {}. Update 
skipped.", LogSanitizer.forLogging(targetId));
                     return EventService.NO_CHANGE;
                 }
             }
@@ -89,22 +108,26 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
             Map<String, Object> propsToAdd = (HashMap<String, Object>) 
event.getProperties().get(PROPS_TO_ADD);
 
             if (propsToAdd != null) {
-                isProfileOrPersonaUpdated |= processProperties(target, 
propsToAdd, "setIfMissing");
+                isProfileOrPersonaUpdated |= processProperties(target, 
propsToAdd, "setIfMissing", trustedCaller);
             }
 
             Map<String, Object> propsToUpdate = (HashMap<String, Object>) 
event.getProperties().get(PROPS_TO_UPDATE);
             if (propsToUpdate != null) {
-                isProfileOrPersonaUpdated |= processProperties(target, 
propsToUpdate, "alwaysSet");
+                isProfileOrPersonaUpdated |= processProperties(target, 
propsToUpdate, "alwaysSet", trustedCaller);
             }
 
             Map<String, Object> propsToAddToSet = (HashMap<String, Object>) 
event.getProperties().get(PROPS_TO_ADD_TO_SET);
             if (propsToAddToSet != null) {
-                isProfileOrPersonaUpdated |= processProperties(target, 
propsToAddToSet, "addValues");
+                isProfileOrPersonaUpdated |= processProperties(target, 
propsToAddToSet, "addValues", trustedCaller);
             }
 
             List<String> propsToDelete = (List<String>) 
event.getProperties().get(PROPS_TO_DELETE);
             if (propsToDelete != null) {
                 for (String prop : propsToDelete) {
+                    if (!trustedCaller && isSystemPropertiesWrite(prop)) {
+                        LOGGER.warn("Refusing systemProperties delete for 
untrusted caller: {}", LogSanitizer.forLogging(prop));
+                        continue;
+                    }
                     isProfileOrPersonaUpdated |= 
PropertyHelper.setProperty(target, prop, null, "remove");
                 }
             }
@@ -140,11 +163,15 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
         }
     }
 
-    private boolean processProperties(Profile target, Map<String, Object> 
propsMap, String strategy) {
+    private boolean processProperties(Profile target, Map<String, Object> 
propsMap, String strategy, boolean trustedCaller) {
         boolean isProfileOrPersonaUpdated = false;
         for (String prop : propsMap.keySet()) {
+            if (!trustedCaller && isSystemPropertiesWrite(prop)) {
+                LOGGER.warn("Refusing systemProperties update for untrusted 
caller: {}", LogSanitizer.forLogging(prop));
+                continue;
+            }
             PropertyType propType = null;
-            if (prop.startsWith("properties.") || 
prop.startsWith("systemProperties.")) {
+            if (prop.startsWith("properties.") || 
prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) {
                 propType = 
profileService.getPropertyType(prop.substring(prop.indexOf('.') + 1));
             } else {
                 propType = profileService.getPropertyType(prop);
@@ -162,6 +189,34 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
         return isProfileOrPersonaUpdated;
     }
 
+
+    /**
+     * Whether a property name writes the reserved {@code systemProperties} 
area.
+     * <p>
+     * Matching the {@code systemProperties.} prefix alone was not enough: the 
bare key
+     * {@code systemProperties} has no dot, so it slipped through, and for a 
flat name
+     * {@link org.apache.unomi.persistence.spi.PropertyHelper#setProperty} 
falls through to
+     * {@code BeanUtils.setProperty}, which invokes {@code 
Profile#setSystemProperties(Map)} and
+     * replaces the entire map. That is strictly more than the per-key write 
this gate blocks — it
+     * is enough to plant {@code mergeIdentifier} and drive the profile-merge 
action.
+     *
+     * @param propertyName the event-supplied property name
+     * @return true when the name targets systemProperties, whether wholesale 
or a single entry
+     */
+    private static boolean isSystemPropertiesWrite(String propertyName) {
+        return SYSTEM_PROPERTIES_KEY.equals(propertyName) || 
propertyName.startsWith(SYSTEM_PROPERTIES_PREFIX);
+    }
+    /**
+     * Whether the caller holds system access, i.e. the administrator or 
tenant administrator role.
+     * <p>
+     * Cross-profile updates and {@code systemProperties.*} writes are 
restricted to such callers.
+     * A tenant private key authenticates as {@link 
UnomiRoles#TENANT_ADMINISTRATOR} and passes; a
+     * tenant public API key or an unauthenticated context event does not.
+     */
+    private boolean isTrustedIdentityCaller() {
+        return securityService != null && securityService.hasSystemAccess();
+    }
+
     public void setProfileService(ProfileService profileService) {
         this.profileService = profileService;
     }
@@ -174,4 +229,8 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
         this.tracerService = tracerService;
     }
 
+    public void setSecurityService(SecurityService securityService) {
+        this.securityService = securityService;
+    }
+
 }
diff --git 
a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 2b10af39a..c317dc34d 100644
--- a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -181,6 +181,7 @@
             <property name="profileService" ref="profileService"/>
             <property name="eventService" ref="eventService"/>
             <property name="tracerService" ref="tracerService"/>
+            <property name="securityService" ref="securityService"/>
         </bean>
     </service>
 
diff --git 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
new file mode 100644
index 000000000..0ae1783aa
--- /dev/null
+++ 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.plugins.baseplugin.actions;
+
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.DefinitionsService;
+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.persistence.spi.PersistenceService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression: public/untrusted events must not rebind a session onto another 
profile via merge.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class MergeProfilesOnPropertyActionTest {
+
+    @Mock private ProfileService profileService;
+    @Mock private PersistenceService persistenceService;
+    @Mock private EventService eventService;
+    @Mock private DefinitionsService definitionsService;
+    @Mock private PrivacyService privacyService;
+    @Mock private SecurityService securityService;
+
+    private MergeProfilesOnPropertyAction actionExecutor;
+
+    @Before
+    public void setUp() {
+        actionExecutor = new MergeProfilesOnPropertyAction();
+        actionExecutor.setProfileService(profileService);
+        actionExecutor.setPersistenceService(persistenceService);
+        actionExecutor.setEventService(eventService);
+        actionExecutor.setDefinitionsService(definitionsService);
+        actionExecutor.setPrivacyService(privacyService);
+        actionExecutor.bindSecurityService(securityService);
+        actionExecutor.setMaxProfilesInOneMerge("50");
+
+        
when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(new
 ConditionType());
+        when(securityService.hasSystemAccess()).thenReturn(false);
+    }
+
+    @Test
+    public void untrustedCaller_cannotMergeIntoExistingOtherProfile() {
+        Profile publicCaller = new Profile("public-caller");
+        Profile other = new Profile("other");
+        other.getSystemProperties().put("mergeIdentifier", 
"[email protected]");
+
+        when(persistenceService.query(any(), anyString(), eq(Profile.class), 
anyInt(), anyInt()))
+                .thenReturn(new PartialList<>(new 
ArrayList<>(Collections.singletonList(other)), 0, 1, 1, 
PartialList.Relation.EQUAL));
+
+        Event event = new Event("login", null, publicCaller, "systemscope", 
null, null, null, new Date(), true);
+        Action action = mergeAction("[email protected]");
+
+        int changes = actionExecutor.execute(action, event);
+
+        // May write mergeIdentifier onto the untrusted caller profile, but 
must not rebind to other
+        assertNotEquals(EventService.PROFILE_UPDATED + 
EventService.SESSION_UPDATED, changes);
+        assertEquals("public-caller", event.getProfile().getItemId());
+        verify(profileService, never()).mergeProfiles(any(), any());
+    }
+
+    @Test
+    public void trustedTenantAdmin_canMergeIntoExistingProfile() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        Profile caller = new Profile("caller");
+        Profile other = new Profile("other");
+        other.setProperty("firstVisit", new Date(0));
+        caller.setProperty("firstVisit", new Date());
+
+        when(persistenceService.query(any(), anyString(), eq(Profile.class), 
anyInt(), anyInt()))
+                .thenReturn(new PartialList<>(new 
ArrayList<>(Collections.singletonList(other)), 0, 1, 1, 
PartialList.Relation.EQUAL));
+        when(profileService.mergeProfiles(eq(other), any())).thenReturn(other);
+        
when(privacyService.isRequireAnonymousBrowsing(any(Profile.class))).thenReturn(false);
+        
when(privacyService.isRequireAnonymousBrowsing("other")).thenReturn(false);
+
+        Event event = new Event("login", null, caller, "systemscope", null, 
null, null, new Date(), true);
+        Action action = mergeAction("[email protected]");
+
+        int changes = actionExecutor.execute(action, event);
+
+        assertEquals(EventService.PROFILE_UPDATED + 
EventService.SESSION_UPDATED, changes);
+        assertEquals("other", event.getProfile().getItemId());
+        verify(profileService).mergeProfiles(eq(other), any());
+    }
+
+    private static Action mergeAction(String mergeValue) {
+        Action action = new Action();
+        Map<String, Object> params = new HashMap<>();
+        params.put("mergeProfilePropertyName", "mergeIdentifier");
+        params.put("mergeProfilePropertyValue", mergeValue);
+        action.setParameterValues(params);
+        return action;
+    }
+}
diff --git 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
new file mode 100644
index 000000000..f1e33efd2
--- /dev/null
+++ 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.plugins.baseplugin.actions;
+
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.ProfileService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression: untrusted events must not update another profile or 
systemProperties.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class UpdatePropertiesActionTest {
+
+    @Mock private ProfileService profileService;
+    @Mock private EventService eventService;
+    @Mock private SecurityService securityService;
+
+    private UpdatePropertiesAction actionExecutor;
+
+    @Before
+    public void setUp() {
+        actionExecutor = new UpdatePropertiesAction();
+        actionExecutor.setProfileService(profileService);
+        actionExecutor.setEventService(eventService);
+        actionExecutor.setSecurityService(securityService);
+
+        when(securityService.hasSystemAccess()).thenReturn(false);
+    }
+
+    @Test
+    public void untrustedCaller_cannotUpdateAnotherProfile() {
+        Profile caller = new Profile("caller");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("properties.email", "changed");
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "other");
+        eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, 
UpdatePropertiesAction.TARGET_TYPE_PROFILE);
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+        Action action = new Action();
+
+        int changes = actionExecutor.execute(action, event);
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        verify(profileService, never()).load(any(String.class));
+        verify(profileService, never()).save(any(Profile.class));
+    }
+
+    @Test
+    public void untrustedCaller_cannotWriteSystemProperties() {
+        Profile caller = new Profile("caller");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("systemProperties.mergeIdentifier", "reused");
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+
+        int changes = actionExecutor.execute(new Action(), event);
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        assertEquals(null, 
caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    /**
+     * The gate matched on the {@code systemProperties.} prefix only, so the 
exact key
+     * {@code systemProperties} slipped past it. That is not a harmless 
near-miss: for a flat name
+     * PropertyHelper falls through to {@code BeanUtils.setProperty}, which 
calls
+     * {@code Profile#setSystemProperties(Map)} and replaces the whole map — a 
superset of the
+     * per-key write the gate exists to block, and enough to plant {@code 
mergeIdentifier} and drive
+     * the profile-merge action.
+     */
+    @Test
+    public void untrustedCaller_cannotReplaceTheWholeSystemPropertiesMap() {
+        Profile caller = new Profile("caller");
+        caller.getSystemProperties().put("mergeIdentifier", 
"[email protected]");
+
+        Map<String, Object> replacement = new HashMap<>();
+        replacement.put("mergeIdentifier", "[email protected]");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("systemProperties", replacement);
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+
+        int changes = actionExecutor.execute(new Action(), event);
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        assertEquals("the untrusted caller must not replace the 
systemProperties map",
+                "[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    /**
+     * The delete mapping does not behave the same way - PropertyHelper's 
remove strategy bails out
+     * for a name with no dot - so this passes even against the unfixed gate. 
Kept as a guard: if that
+     * remove path ever learns to handle flat names, this catches it rather 
than the next reporter.
+     */
+    @Test
+    public void untrustedCaller_cannotDeleteTheWholeSystemPropertiesMap() {
+        Profile caller = new Profile("caller");
+        caller.getSystemProperties().put("mergeIdentifier", 
"[email protected]");
+
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_DELETE,
+                java.util.Collections.singletonList("systemProperties"));
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+
+        actionExecutor.execute(new Action(), event);
+
+        assertEquals("the untrusted caller must not clear the systemProperties 
map",
+                "[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    /** A trusted caller is still allowed to set it, so the gate is a trust 
check and not a ban. */
+    @Test
+    public void trustedAdmin_mayReplaceTheSystemPropertiesMap() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        Profile caller = new Profile("caller");
+        Map<String, Object> replacement = new HashMap<>();
+        replacement.put("mergeIdentifier", "[email protected]");
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("systemProperties", replacement);
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+
+        actionExecutor.execute(new Action(), event);
+
+        assertEquals("[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    @Test
+    public void trustedAdmin_canUpdateAnotherProfile() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        Profile caller = new Profile("caller");
+        Profile other = new Profile("other");
+        when(profileService.load("other")).thenReturn(other);
+
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("properties.email", "admin-set");
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "other");
+        eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, 
UpdatePropertiesAction.TARGET_TYPE_PROFILE);
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+        Event event = new Event("updateProperties", null, caller, 
"systemscope", null, null, eventProps, new Date(), true);
+
+        actionExecutor.execute(new Action(), event);
+
+        verify(profileService).load("other");
+        verify(profileService).save(other);
+    }
+}
diff --git a/samples/login-integration/pom.xml 
b/samples/login-integration/pom.xml
index 772e2aeab..6d10ebfa5 100644
--- a/samples/login-integration/pom.xml
+++ b/samples/login-integration/pom.xml
@@ -25,7 +25,7 @@
     </parent>
     <artifactId>login-integration-sample</artifactId>
     <name>Apache Unomi :: Samples :: External Login plugin</name>
-    <description>This is a simple Apache Unomi plugin.</description>
+    <description>Server-side login sample that calls Unomi with trusted 
credentials so profile merge works (UNOMI-972).</description>
     <packaging>bundle</packaging>
 
     <dependencyManagement>
@@ -42,16 +42,40 @@
 
     <dependencies>
         <dependency>
-            <groupId>org.apache.unomi</groupId>
-            <artifactId>unomi-api</artifactId>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
             <scope>provided</scope>
         </dependency>
         <dependency>
-            <groupId>javax.servlet.jsp</groupId>
-            <artifactId>jsp-api</artifactId>
-            <version>2.1</version>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope>
+        </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>
@@ -62,10 +86,10 @@
                 <extensions>true</extensions>
                 <configuration>
                     <instructions>
-                        <_wab>src/main/webapp</_wab>
-                        
<Embed-Dependency>*;scope=compile|runtime</Embed-Dependency>
-                        <Embed-Directory>WEB-INF/lib</Embed-Directory>
-                        <Web-ContextPath>/login</Web-ContextPath>
+                        <_dsannotations>*</_dsannotations>
+                        <_metatypeannotations>*</_metatypeannotations>
+                        <Export-Package>!*</Export-Package>
+                        
<Private-Package>org.apache.unomi.samples.login.*</Private-Package>
                     </instructions>
                 </configuration>
             </plugin>
diff --git a/samples/login-integration/setup.sh 
b/samples/login-integration/setup.sh
new file mode 100755
index 000000000..ea56b13eb
--- /dev/null
+++ b/samples/login-integration/setup.sh
@@ -0,0 +1,299 @@
+#!/bin/sh
+#
+# 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.
+#
+# Provisions and configures the Apache Unomi login sample on a LOCAL instance.
+#
+# This is a demo convenience, not a deployment tool. It uses the system 
administrator
+# credential because provisioning a tenant is an operator action - the sample 
servlet itself
+# only ever receives the scoped tenant private key this script creates for it.
+#
+# Idempotent: re-running reuses an existing tenant and scope, and issues a 
fresh private key.
+#
+# Usage:
+#   export UNOMI_ROOT_PASSWORD='your-admin-password'
+#   ./setup.sh [--version <sample-version>]
+#
+# Optional environment overrides:
+#   UNOMI_URL        base URL of the running Unomi   (default 
http://localhost:8181)
+#   UNOMI_TENANT_ID  tenant to create/use            (default default)
+#   UNOMI_SCOPE      scope to create/use             (default default)
+#   KARAF_HOME       Unomi install dir (auto-detected in the source tree)
+#   DEMO_PASSWORD    login-form password             (default: randomly 
generated)
+
+set -eu
+
+UNOMI_URL="${UNOMI_URL:-http://localhost:8181}";
+TENANT_ID="${UNOMI_TENANT_ID:-default}"
+SCOPE="${UNOMI_SCOPE:-default}"
+PID="org.apache.unomi.samples.login"
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+SAMPLE_VERSION=""
+
+usage() {
+    cat <<'USAGE'
+Provisions and configures the Apache Unomi login sample on a local instance.
+
+  export UNOMI_ROOT_PASSWORD='your-admin-password'
+  ./setup.sh [--version <sample-version>]
+
+Environment overrides: UNOMI_URL, UNOMI_TENANT_ID, UNOMI_SCOPE, KARAF_HOME, 
DEMO_PASSWORD.
+USAGE
+}
+
+fail() { echo "ERROR: $*" >&2; exit 1; }
+
+# The cfg is parsed as a Java .properties file, which treats backslash as an 
escape character and
+# strips whitespace between the separator and the value. Writing a value 
verbatim would therefore
+# store something different from what the operator typed - "p@ss\\word" 
silently becomes
+# "p@ssword" - and the resulting login failure gives no clue why. Escape 
backslashes, then escape a
+# leading space or tab so it survives.
+properties_escape() {
+    printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/^\([ \t]\)/\\\1/'
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --version) SAMPLE_VERSION="${2:?--version needs a value}"; shift 2 ;;
+        -h|--help) usage; exit 0 ;;
+        *) echo "Unknown argument: $1 (try --help)" >&2; exit 2 ;;
+    esac
+done
+
+for cmd in curl jq; do
+    command -v "$cmd" >/dev/null 2>&1 || fail "$cmd is required but not on 
PATH"
+done
+
+[ -n "${UNOMI_ROOT_PASSWORD:-}" ] || fail "UNOMI_ROOT_PASSWORD is not set. 
Export the administrator
+       password you started Unomi with, then run this script again."
+
+# Configuration and deployment go through the directories Karaf already watches
+# (felix.fileinstall polls etc/ for *.cfg and deploy/ for bundles), so this 
script needs no Karaf
+# console: no SSH, no host keys, and no console credential on the command line.
+#
+# That also means everything is written to a directory rather than to the 
running process, so the
+# directory has to be validated properly: pointing at a freshly built 
distribution while a different
+# one is actually serving UNOMI_URL would write the config into an install 
nobody is reading.
+if [ -n "${KARAF_HOME:-}" ]; then
+    KARAF_DIR="$KARAF_HOME"
+else
+    KARAF_DIR=""
+    for candidate in "${SCRIPT_DIR}"/../../package/target/unomi-*/; do
+        [ -f "${candidate}etc/config.properties" ] || continue
+        [ -z "$KARAF_DIR" ] || fail "Found more than one built distribution 
under package/target.
+       Set KARAF_HOME to the one that is running."
+        KARAF_DIR="$candidate"
+    done
+    [ -n "$KARAF_DIR" ] || fail "Could not find a Unomi install. Set 
KARAF_HOME to the directory of the
+       running instance, for example:
+         KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh"
+fi
+
+[ -d "$KARAF_DIR" ] || fail "KARAF_HOME does not exist: ${KARAF_DIR}"
+KARAF_DIR=$(CDPATH= cd -- "$KARAF_DIR" && pwd)
+
+# Looks like a Karaf install at all?
+for marker in etc/config.properties bin/karaf deploy; do
+    [ -e "${KARAF_DIR}/${marker}" ] \
+        || fail "${KARAF_DIR} does not look like a Unomi install (missing 
${marker}). Set KARAF_HOME."
+done
+
+# Writable? Failing here beats a half-applied setup.
+for dir in etc deploy; do
+    [ -w "${KARAF_DIR}/${dir}" ] || fail "${KARAF_DIR}/${dir} is not writable 
by $(id -un)."
+done
+
+# Actually running? Karaf writes karaf.pid at startup; a stale file from a 
previous run is common,
+# so the process is checked rather than just the file. Without this the script 
would happily
+# configure a stopped install and only fail later, at the readiness poll, with 
a confusing message.
+KARAF_PID_FILE="${KARAF_DIR}/karaf.pid"
+[ -f "$KARAF_PID_FILE" ] || fail "No karaf.pid in ${KARAF_DIR} - that instance 
has never been started.
+       Start Unomi there, or point KARAF_HOME at the instance serving 
${UNOMI_URL}."
+KARAF_PID=$(cat "$KARAF_PID_FILE" 2>/dev/null || true)
+{ [ -n "$KARAF_PID" ] && kill -0 "$KARAF_PID" 2>/dev/null; } \
+    || fail "${KARAF_DIR} has a stale karaf.pid (process ${KARAF_PID:-unknown} 
is not running).
+       Start that instance, or point KARAF_HOME at the one serving 
${UNOMI_URL}."
+
+echo "==> Using Unomi install ${KARAF_DIR} (running, pid ${KARAF_PID})"
+
+# Credentials go into a mode-600 netrc rather than curl --user: --user places 
the password in the
+# process arguments, where any local user can read it from ps for the lifetime 
of the request.
+NETRC=$(mktemp) || fail "Could not create a temporary file"
+chmod 600 "$NETRC"
+trap 'rm -f "$NETRC"' EXIT INT TERM HUP
+UNOMI_HOST=$(printf '%s' "$UNOMI_URL" | sed -e 
's,^[A-Za-z][A-Za-z0-9+.-]*://,,' -e 's,[:/].*$,,')
+[ -n "$UNOMI_HOST" ] || fail "Could not parse a host out of 
UNOMI_URL='${UNOMI_URL}'"
+printf 'machine %s login karaf password %s\n' "$UNOMI_HOST" 
"$UNOMI_ROOT_PASSWORD" > "$NETRC"
+
+# curl exits 0 for any completed HTTP transaction, including 401/403/500, so a 
bare "curl || fail"
+# reports failed requests as successes. Every call therefore checks the status 
code explicitly and
+# surfaces it, rather than relying on curl's exit status.
+#   $1 = description used in the error message; remaining args go to curl; 
body goes to stdout.
+admin_request() {
+    _what="$1"; shift
+    _out=$(mktemp) || fail "Could not create a temporary file"
+    _code=$(curl -sS -o "$_out" -w '%{http_code}' --netrc-file "$NETRC" "$@" 
2>/dev/null) || _code="000"
+    case "$_code" in
+        2*)
+            cat "$_out"; rm -f "$_out"; return 0 ;;
+        000)
+            rm -f "$_out"
+            fail "${_what}: could not connect to ${UNOMI_URL}. Is Unomi 
running?" ;;
+        401|403)
+            rm -f "$_out"
+            fail "${_what}: HTTP ${_code}. Check that UNOMI_ROOT_PASSWORD is 
the administrator
+       password of the instance at ${UNOMI_URL}." ;;
+        *)
+            _body=$(head -c 400 "$_out" 2>/dev/null || true); rm -f "$_out"
+            fail "${_what}: HTTP ${_code}. ${_body}" ;;
+    esac
+}
+
+# Existence checks must NOT fail on a non-2xx: "not found" is the normal 
create-it path, and the
+# endpoints differ in how they say it (404, or an empty 2xx body). These two 
helpers therefore
+# report what came back instead of treating it as an error; a genuine 
permission problem still
+# surfaces loudly at the following create call, which goes through 
admin_request.
+admin_status() { curl -sS -o /dev/null -w '%{http_code}' --netrc-file "$NETRC" 
"$@" 2>/dev/null || echo "000"; }
+admin_body_or_empty() { curl -sS --netrc-file "$NETRC" "$@" 2>/dev/null || 
true; }
+
+echo "==> Checking Unomi at ${UNOMI_URL}"
+admin_request "Connecting to ${UNOMI_URL}" "${UNOMI_URL}/cxs/tenants" 
>/dev/null
+echo "    reachable, administrator credentials accepted"
+
+echo "==> Tenant '${TENANT_ID}'"
+if [ "$(admin_status "${UNOMI_URL}/cxs/tenants/${TENANT_ID}")" = "200" ]; then
+    echo "    already exists, reusing"
+else
+    admin_request "Creating tenant '${TENANT_ID}'" -X POST 
"${UNOMI_URL}/cxs/tenants" \
+        -H "Content-Type: application/json" \
+        -d "{\"requestedId\":\"${TENANT_ID}\",\"properties\":{\"name\":\"Login 
sample tenant\"}}" \
+        >/dev/null
+    echo "    created"
+fi
+
+echo "==> Scope '${SCOPE}'"
+existing_scope=$(admin_body_or_empty -H "X-Unomi-Tenant-Id: ${TENANT_ID}" 
"${UNOMI_URL}/cxs/scopes/${SCOPE}")
+if printf '%s' "$existing_scope" | jq -e '.itemId? // empty' >/dev/null 2>&1; 
then
+    echo "    already exists, reusing"
+else
+    admin_request "Creating scope '${SCOPE}'" -X POST 
"${UNOMI_URL}/cxs/scopes" \
+        -H "Content-Type: application/json" \
+        -H "X-Unomi-Tenant-Id: ${TENANT_ID}" \
+        -d 
"{\"itemId\":\"${SCOPE}\",\"metadata\":{\"id\":\"${SCOPE}\",\"name\":\"Login 
sample scope\"}}" \
+        >/dev/null
+    echo "    created"
+fi
+
+# The plaintext of a private key is returned once, at creation, so it is 
captured here and never
+# echoed. An empty value means the request failed; configuring the sample with 
it would leave the
+# servlet with a blank credential.
+echo "==> Issuing a tenant private key"
+PRIVATE_KEY=$(admin_request "Issuing a private key for '${TENANT_ID}'" \
+    -X POST "${UNOMI_URL}/cxs/tenants/${TENANT_ID}/apikeys?type=PRIVATE" \
+    | jq -r '.plainTextKey // empty')
+[ -n "$PRIVATE_KEY" ] || fail "No plainTextKey was returned. Check that tenant 
'${TENANT_ID}' exists
+       and that the administrator credentials are correct."
+echo "    issued (not printed)"
+
+# No demo password ships with the sample, for the same reason Unomi ships no 
default admin password.
+if [ -n "${DEMO_PASSWORD:-}" ]; then
+    case "$DEMO_PASSWORD" in
+        *"$(printf '\n')"*) fail "DEMO_PASSWORD must not contain a newline." ;;
+    esac
+    LOGIN_PASSWORD="$DEMO_PASSWORD"
+else
+    LOGIN_PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20) \
+        || fail "Could not generate a demo password"
+fi
+
+# Written before the bundle is deployed so the component sees its 
configuration on first activation.
+# Holds the private key and the demo password, hence the restrictive mode.
+echo "==> Writing ${KARAF_DIR}/etc/${PID}.cfg"
+CFG="${KARAF_DIR}/etc/${PID}.cfg"
+# Scoped so the restrictive mode applies to the cfg only: leaving umask 077 
set would also strip
+# group/other bits from the bundle jar copied into deploy/ further down.
+_previous_umask=$(umask)
+umask 077
+cat > "$CFG" <<EOF
+# Generated by samples/login-integration/setup.sh - safe to edit or delete.
+unomiBaseUrl=${UNOMI_URL}
+tenantId=${TENANT_ID}
+scope=${SCOPE}
+privateKey=$(properties_escape "$PRIVATE_KEY")
+demoPassword=$(properties_escape "$LOGIN_PASSWORD")
+EOF
+chmod 600 "$CFG"
+umask "$_previous_umask"
+echo "    written (mode 600)"
+
+echo "==> Deploying the sample bundle"
+SAMPLE_JAR=$(ls "${SCRIPT_DIR}"/target/login-integration-sample-*.jar 
2>/dev/null | head -1)
+[ -n "$SAMPLE_JAR" ] || fail "No built bundle in ${SCRIPT_DIR}/target.
+       Build it first: mvn -pl samples/login-integration -am install 
-DskipTests"
+cp "$SAMPLE_JAR" "${KARAF_DIR}/deploy/" || fail "Could not copy the bundle 
into ${KARAF_DIR}/deploy"
+echo "    copied $(basename "$SAMPLE_JAR") into deploy/"
+
+# Karaf polls deploy/ once a second, so confirm the sample really came up 
rather than reporting
+# success on the basis of having copied a file.
+#
+# Probe the servlet, not the static page: /login/index.html is published by 
LoginSampleResources,
+# a separate component with no configuration dependency, so it answers 200 as 
soon as the bundle
+# resolves - even while LoginServlet is still unconfigured and every login 
returns 503. That window
+# is real, not theoretical: the two components activate independently as 
fileinstall picks up the
+# cfg and the jar. Posting a deliberately wrong password distinguishes the 
states:
+#   401 = servlet up AND configured (it got as far as checking the password)
+#   503 = deployed but not configured yet
+#   404/000 = not deployed yet
+echo "==> Waiting for the sample to answer"
+DEPLOY_STATUS=""
+i=0
+while [ $i -lt 30 ]; do
+    DEPLOY_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
+        --data 
"[email protected]&password=setup-probe-wrong-password" \
+        "${UNOMI_URL}/login/authenticate" 2>/dev/null || echo "000")
+    [ "$DEPLOY_STATUS" = "401" ] && break
+    sleep 1
+    i=$((i + 1))
+done
+if [ "$DEPLOY_STATUS" != "401" ]; then
+    case "$DEPLOY_STATUS" in
+        503) fail "The sample deployed but never picked up its configuration 
(still HTTP 503 after ${i}s).
+       Check ${CFG} and ${KARAF_DIR}/data/log/karaf.log for a line starting 
'Login sample'." ;;
+        *)   fail "The sample did not come up at 
${UNOMI_URL}/login/authenticate after ${i}s (last status
+       ${DEPLOY_STATUS}). Check ${KARAF_DIR}/data/log/karaf.log for a line 
starting 'Login sample'." ;;
+    esac
+fi
+echo "    up and configured after ${i}s"
+
+cat <<EOF
+
+========================================================================
+ Login sample is ready.
+
+   Page:           ${UNOMI_URL}/login/index.html
+   Demo password:  ${LOGIN_PASSWORD}
+
+ Log in with any email address and the password above. The password was
+ generated for this run - it is not stored anywhere else, so copy it now.
+========================================================================
+
+To remove the sample, run:
+
+  rm -f "${KARAF_DIR}/deploy/$(basename "$SAMPLE_JAR")"
+  rm -f "${CFG}"
+
+Karaf uninstalls the bundle as soon as the jar disappears from deploy/.
+EOF
diff --git 
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
new file mode 100644
index 000000000..550656d17
--- /dev/null
+++ 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
@@ -0,0 +1,37 @@
+/*
+ * 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.samples.login;
+
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * Publishes the sample HTML/JS under {@code /login/*} via the OSGi Http 
Whiteboard
+ * (files live in {@code /static} inside this bundle).
+ * <p>
+ * Static resources only: there is no directory index, so the page is reached 
at
+ * {@code /login/index.html} rather than {@code /login}.
+ */
+@Component(
+        service = Object.class,
+        immediate = true,
+        property = {
+                "osgi.http.whiteboard.resource.pattern=/login/*",
+                "osgi.http.whiteboard.resource.prefix=/static"
+        }
+)
+public class LoginSampleResources {
+}
diff --git 
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
new file mode 100644
index 000000000..b603ce6ed
--- /dev/null
+++ 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
@@ -0,0 +1,395 @@
+/*
+ * 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.samples.login;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.Servlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Demo "authentication server" for the login sample.
+ * <p>
+ * The browser posts the form here. This servlet checks a hardcoded demo 
password,
+ * then calls Unomi {@code /cxs/context.json} with <strong>trusted</strong> 
Basic
+ * credentials (a tenant private key) so {@code mergeProfilesOnPropertyAction}
+ * is allowed. The browser must not call Unomi for login events itself.
+ */
+@Component(
+        service = Servlet.class,
+        immediate = true,
+        configurationPid = "org.apache.unomi.samples.login",
+        property = {
+                "osgi.http.whiteboard.servlet.name=LoginSampleServlet",
+                "osgi.http.whiteboard.servlet.pattern=/login/authenticate"
+        }
+)
+@Designate(ocd = LoginServlet.Config.class)
+public class LoginServlet extends HttpServlet {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(LoginServlet.class);
+    /** Must match the {@code configurationPid} above; quoted in the hint 
printed when config is missing. */
+    private static final String CONFIGURATION_PID = 
"org.apache.unomi.samples.login";
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    /** Attribute holding the Unomi session id we generated for this browser's 
container session. */
+    private static final String UNOMI_SESSION_ID_ATTRIBUTE = 
"org.apache.unomi.samples.login.unomiSessionId";
+    /**
+     * Idle timeout applied to the container sessions this servlet creates. A 
login round trip takes
+     * seconds, so a few minutes is generous; see {@link #resolveSessionId} 
for why it is capped.
+     */
+    private static final int SESSION_MAX_INACTIVE_SECONDS = 300;
+
+    private String unomiBaseUrl = "http://localhost:8181";;
+    private String tenantId = "default";
+    private String scope = "default";
+    private String privateKey = "";
+    private String demoPassword = "";
+
+    @ObjectClassDefinition(
+            name = "Unomi login sample",
+            description = "Trusted credentials used by /login/authenticate to 
call Unomi (UNOMI-972)"
+    )
+    public @interface Config {
+
+        @AttributeDefinition(name = "Unomi base URL", description = "Base URL 
of this Unomi instance")
+        String unomiBaseUrl() default "http://localhost:8181";;
+
+        @AttributeDefinition(
+                name = "Tenant ID",
+                description = "Tenant the login events belong to. Sent as the 
Basic auth user name alongside privateKey."
+        )
+        String tenantId() default "default";
+
+        @AttributeDefinition(
+                name = "Scope",
+                description = "Event/source scope (must already exist for the 
tenant; systemscope is not a valid event scope)"
+        )
+        String scope() default "default";
+
+        @AttributeDefinition(
+                name = "Tenant private key",
+                description = "Required. Plain-text tenant private API key; 
authenticates as tenant "
+                        + "administrator, which is what allows the profile 
merge."
+        )
+        String privateKey() default "";
+
+        @AttributeDefinition(
+                name = "Demo login password",
+                description = "Password the sample login form accepts. 
Required; no default is shipped, "
+                        + "so choose one when configuring the sample. Stands 
in for the user directory "
+                        + "a real integration would authenticate against."
+        )
+        String demoPassword() default "";
+    }
+
+    @Activate
+    @Modified
+    public void activate(Config config) {
+        this.unomiBaseUrl = config.unomiBaseUrl();
+        this.tenantId = config.tenantId();
+        this.scope = config.scope() != null && !config.scope().isBlank() ? 
config.scope().trim() : "default";
+        this.privateKey = config.privateKey() != null ? 
config.privateKey().trim() : "";
+        this.demoPassword = config.demoPassword() != null ? 
config.demoPassword().trim() : "";
+        logConfigurationStatus();
+    }
+
+    /**
+     * Reports whether the sample is usable, so that starting the bundle after 
configuring it is a
+     * self-checking step. Re-runs on every configuration update because 
{@link Modified} is applied
+     * to {@link #activate}, so correcting a value and running {@code 
config:update} reprints this.
+     * <p>
+     * Never logs a credential, only whether one is present.
+     */
+    private void logConfigurationStatus() {
+        List<String> missing = new ArrayList<>();
+        if (demoPassword.isEmpty()) {
+            missing.add("demoPassword (the password the login form accepts)");
+        }
+        if (privateKey.isEmpty()) {
+            missing.add("privateKey (a tenant private API key)");
+        }
+
+        if (missing.isEmpty()) {
+            LOGGER.info("Login sample ready - open {}/login/index.html 
(tenantId={}, scope={})",
+                    unomiBaseUrl, tenantId, scope);
+            return;
+        }
+
+        LOGGER.warn("Login sample is NOT usable yet, missing configuration: 
{}.\n"
+                        + "Set it from the Karaf console, then start the 
bundle again:\n"
+                        + "  config:edit {}\n"
+                        + "  config:property-set demoPassword 
<choose-a-password>\n"
+                        + "  config:property-set privateKey 
<tenant-private-key>\n"
+                        + "  config:update",
+                String.join(", ", missing), CONFIGURATION_PID);
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse resp) 
throws IOException {
+        // This endpoint is an unauthenticated state-changing POST that then 
calls Unomi with trusted
+        // credentials, so a hostile page could otherwise drive it from a 
visitor's browser. A real
+        // integration must use a proper per-session CSRF token; this 
same-origin check is only the
+        // lightweight equivalent that fits a sample.
+        if (!isSameOrigin(req)) {
+            writeError(resp, HttpServletResponse.SC_FORBIDDEN, "Cross-origin 
request rejected");
+            return;
+        }
+
+        String email = trim(req.getParameter("email"));
+        String firstName = trim(req.getParameter("firstName"));
+        String lastName = trim(req.getParameter("lastName"));
+        String password = trim(req.getParameter("password"));
+
+        // No demo password ships with the sample, for the same reason Unomi 
itself no longer ships a
+        // default admin password: a credential baked into published source is 
a credential everyone has.
+        if (demoPassword.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+                    "demoPassword is not configured: run 'config:edit " + 
CONFIGURATION_PID + "', "
+                            + "'config:property-set demoPassword <password>', 
'config:update'");
+            return;
+        }
+        if (!demoPassword.equals(password)) {
+            writeError(resp, HttpServletResponse.SC_UNAUTHORIZED, "Invalid 
credentials");
+            return;
+        }
+        if (email.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "email is 
required");
+            return;
+        }
+
+        // The session id must be derived from state this servlet controls, 
never from the request
+        // parameters. We call Unomi with trusted credentials, and a trusted 
caller is allowed to
+        // adopt whatever profile owns the session id it passes: forwarding a 
client-supplied id
+        // would launder untrusted client input across the trust boundary and 
let anyone who guesses
+        // another visitor's session id rebind or merge that visitor's 
profile. Storing a generated
+        // id on the container's own HttpSession keeps it unreachable from the 
page while staying stable
+        // across requests from the same browser, which is what lets Unomi 
recover the visitor's
+        // pre-login anonymous profile.
+        String sessionId = resolveSessionId(req);
+
+        // Only a tenant private key. A system administrator credential would 
also satisfy the merge
+        // gate, but it grants far more than this sample needs and is scoped 
to the whole instance
+        // rather than one tenant, so it is deliberately not accepted here.
+        if (privateKey.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+                    "privateKey is not configured: run 'config:edit " + 
CONFIGURATION_PID + "', "
+                            + "'config:property-set privateKey 
<tenant-private-key>', 'config:update'");
+            return;
+        }
+
+        ObjectNode contextRequest = MAPPER.createObjectNode();
+        ObjectNode source = contextRequest.putObject("source");
+        source.put("itemId", "/login");
+        source.put("itemType", "page");
+        source.put("scope", scope);
+
+        ArrayNode events = contextRequest.putArray("events");
+        ObjectNode loginEvent = events.addObject();
+        loginEvent.put("eventType", "login");
+        loginEvent.put("scope", scope);
+        ObjectNode target = loginEvent.putObject("target");
+        target.put("itemId", email);
+        target.put("itemType", "exampleUser");
+        ObjectNode targetProps = target.putObject("properties");
+        targetProps.put("email", email);
+        targetProps.put("firstName", firstName);
+        targetProps.put("lastName", lastName);
+
+        contextRequest.set("requiredProfileProperties", 
MAPPER.valueToTree(List.of("*")));
+        contextRequest.set("requiredSessionProperties", 
MAPPER.valueToTree(List.of("*")));
+
+        byte[] body = MAPPER.writeValueAsBytes(contextRequest);
+
+        int status;
+        JsonNode responseJson;
+        List<String> setCookieValues;
+        try {
+            URL url = new URL(unomiBaseUrl.replaceAll("/$", "") + 
"/cxs/context.json?sessionId="
+                    + URLEncoder.encode(sessionId, StandardCharsets.UTF_8));
+            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("POST");
+            conn.setDoOutput(true);
+            conn.setConnectTimeout(10000);
+            conn.setReadTimeout(30000);
+            conn.setRequestProperty("Content-Type", "application/json; 
charset=utf-8");
+            conn.setRequestProperty("Accept", "application/json");
+            // The tenant is the Basic auth user name: Unomi derives the 
tenant context from the key
+            // itself, so no X-Unomi-Tenant-Id header is needed.
+            String token = Base64.getEncoder()
+                    .encodeToString((tenantId + ":" + 
privateKey).getBytes(StandardCharsets.UTF_8));
+            conn.setRequestProperty("Authorization", "Basic " + token);
+
+            try (OutputStream os = conn.getOutputStream()) {
+                os.write(body);
+            }
+
+            status = conn.getResponseCode();
+            InputStream stream = status >= 400 ? conn.getErrorStream() : 
conn.getInputStream();
+            if (stream != null) {
+                responseJson = MAPPER.readTree(stream);
+            } else {
+                responseJson = MAPPER.createObjectNode();
+            }
+            // getHeaderField() only returns the first value: Unomi can set 
several cookies
+            // (profile id and session id), so every value has to be forwarded.
+            setCookieValues = headerValues(conn, "Set-Cookie");
+        } catch (IOException e) {
+            // Never let the container render a stack trace: it would disclose 
the Unomi endpoint
+            // and internal class names to an unauthenticated caller.
+            LOGGER.warn("Login sample could not complete the call to Unomi", 
e);
+            writeError(resp, HttpServletResponse.SC_BAD_GATEWAY, "Profile 
service unavailable, please try again later");
+            return;
+        }
+
+        if (setCookieValues != null) {
+            for (String setCookie : setCookieValues) {
+                if (setCookie != null) {
+                    resp.addHeader("Set-Cookie", setCookie);
+                }
+            }
+        }
+        resp.setStatus(status);
+        resp.setContentType("application/json; charset=utf-8");
+        MAPPER.writeValue(resp.getOutputStream(), responseJson);
+    }
+
+    /**
+     * Returns <em>all</em> values of a response header. Unlike {@code 
getHeaderFields().get(name)},
+     * this keeps the case-insensitive matching that {@code 
getHeaderField(name)} provided.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
cover it without
+     * reflection.
+     */
+    static List<String> headerValues(HttpURLConnection conn, String name) {
+        for (Map.Entry<String, List<String>> entry : 
conn.getHeaderFields().entrySet()) {
+            if (name.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the Unomi session id bound to this browser's container session, 
generating one on
+     * first use. Deliberately not read from any request parameter or header — 
see the call site.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
exercise the trust
+     * boundary directly instead of going through reflection.
+     */
+    static String resolveSessionId(HttpServletRequest req) {
+        HttpSession httpSession = req.getSession(true);
+        // Guard against two concurrent first requests from the same browser 
generating two different
+        // ids. The session object is the conventional mutex here; do not lock 
on an interned session
+        // id, which shares a JVM-wide monitor with any other code that 
interns the same value.
+        synchronized (httpSession) {
+            Object existing = 
httpSession.getAttribute(UNOMI_SESSION_ID_ATTRIBUTE);
+            if (existing instanceof String && !((String) existing).isEmpty()) {
+                return (String) existing;
+            }
+            String generated = UUID.randomUUID().toString();
+            httpSession.setAttribute(UNOMI_SESSION_ID_ATTRIBUTE, generated);
+            // Bound the lifetime of the sessions this servlet creates. The 
demo password gate above
+            // is NOT authentication: it is a single shared demo password, so
+            // anyone can pass it repeatedly while discarding the session 
cookie each time. Every such
+            // POST would otherwise pin a container session in memory for the 
container's default
+            // timeout (commonly 30 minutes), which is a cheap 
memory-exhaustion path. Expiring these
+            // sessions after a few minutes keeps the id stable for a real 
browser's login round trip
+            // while letting the container reclaim the throwaway ones almost 
immediately.
+            httpSession.setMaxInactiveInterval(SESSION_MAX_INACTIVE_SECONDS);
+            return generated;
+        }
+    }
+
+    /**
+     * Lightweight CSRF defence: when the browser sends an {@code Origin} 
header it must match the
+     * origin this request was addressed to. A missing header (same-origin 
form posts on older
+     * browsers, curl) is tolerated; an unparsable or mismatching one is 
rejected.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
cover it without
+     * reflection.
+     */
+    static boolean isSameOrigin(HttpServletRequest req) {
+        String origin = trim(req.getHeader("Origin"));
+        if (origin.isEmpty()) {
+            return true;
+        }
+        URI originUri;
+        try {
+            originUri = new URI(origin);
+        } catch (URISyntaxException e) {
+            LOGGER.debug("Rejecting login request with unparsable Origin 
header", e);
+            return false;
+        }
+        String originScheme = originUri.getScheme();
+        String originHost = originUri.getHost();
+        if (originScheme == null || originHost == null) {
+            // Includes the opaque "null" origin sent by sandboxed frames.
+            return false;
+        }
+        return originScheme.equalsIgnoreCase(req.getScheme())
+                && originHost.equalsIgnoreCase(req.getServerName())
+                && defaultedPort(originScheme, originUri.getPort()) == 
req.getServerPort();
+    }
+
+    private static int defaultedPort(String scheme, int port) {
+        if (port != -1) {
+            return port;
+        }
+        return "https".equalsIgnoreCase(scheme) ? 443 : 80;
+    }
+
+    private static void writeError(HttpServletResponse resp, int status, 
String message) throws IOException {
+        Map<String, String> error = new LinkedHashMap<>();
+        error.put("error", message);
+        resp.setStatus(status);
+        resp.setContentType("application/json; charset=utf-8");
+        MAPPER.writeValue(resp.getOutputStream(), error);
+    }
+
+    private static String trim(String s) {
+        return s == null ? "" : s.trim();
+    }
+}
diff --git a/samples/login-integration/src/main/resources/static/index.html 
b/samples/login-integration/src/main/resources/static/index.html
new file mode 100644
index 000000000..7f2c995b4
--- /dev/null
+++ b/samples/login-integration/src/main/resources/static/index.html
@@ -0,0 +1,75 @@
+<!--
+  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.
+-->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Unomi login sample</title>
+    <link rel="stylesheet" 
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css";
+          
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
 crossorigin="anonymous">
+    <script 
src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js";></script>
+    <script src="javascript/login-example.js"></script>
+</head>
+<body>
+<div class="container" style="max-width: 720px; margin-top: 2em;">
+    <h1>Login integration sample</h1>
+
+    <div id="alert_placeholder"></div>
+
+    <form id="loginForm">
+        <div class="form-group">
+            <label for="firstname">First name</label>
+            <input type="text" name="firstName" id="firstname" 
class="form-control" placeholder="First name"/>
+        </div>
+        <div class="form-group">
+            <label for="lastname">Last name</label>
+            <input type="text" name="lastName" id="lastname" 
class="form-control" placeholder="Last name"/>
+        </div>
+        <div class="form-group">
+            <label for="email">Email (merge key)</label>
+            <input type="text" name="email" id="email" class="form-control" 
placeholder="[email protected]" required/>
+        </div>
+        <div class="form-group">
+            <label for="password">Password</label>
+            <input type="password" name="password" id="password" 
class="form-control" placeholder="password printed by setup.sh" required/>
+        </div>
+        <button type="submit" class="btn btn-primary">Login</button>
+    </form>
+
+    <p class="help-block" style="margin-top: 1.5em;">
+        To test merge: login once, note <code>profileId</code>, clear the 
<code>context-profile-id</code> cookie
+        (or use a private window), login again with the <strong>same 
email</strong> — you should get the same master profile.
+    </p>
+
+    <div class="panel panel-info" style="margin-top: 1.5em;">
+        <div class="panel-heading"><strong>How this sample works</strong></div>
+        <div class="panel-body">
+            <ol>
+                <li>This page posts the form to a <strong>server-side 
servlet</strong>
+                    (<code>/login/authenticate</code>) — not to 
<code>/cxs/context.json</code>.</li>
+                <li>The servlet checks the demo password <code>setup.sh</code> 
generated and stored as
+                    <code>demoPassword</code>, then calls Unomi with 
<strong>trusted</strong> Basic
+                    credentials (a tenant private key).</li>
+                <li>The bundled <code>exampleLogin</code> rule merges on email
+                    <code>mergeProfilesOnPropertyAction</code> (trusted 
callers only, UNOMI-972).</li>
+            </ol>
+        </div>
+    </div>
+</div>
+</body>
+</html>
diff --git 
a/samples/login-integration/src/main/resources/static/javascript/login-example.js
 
b/samples/login-integration/src/main/resources/static/javascript/login-example.js
new file mode 100644
index 000000000..bbc82fac2
--- /dev/null
+++ 
b/samples/login-integration/src/main/resources/static/javascript/login-example.js
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+(function () {
+    // No session id is generated or sent from the browser. 
/login/authenticate calls Unomi with
+    // trusted credentials, and a trusted caller is allowed to adopt the 
profile that owns the
+    // session id it passes, so the id must come from server-side state only 
(the servlet derives
+    // it from its own HttpSession). Sending a client-chosen id here would let 
anyone rebind
+    // another visitor's profile.
+
+    function show(ok, message) {
+        var cls = ok ? "alert-success" : "alert-danger";
+        $("#alert_placeholder").html(
+            '<div class="alert ' + cls + '"><a class="close" 
data-dismiss="alert">×</a><span></span></div>'
+        );
+        $("#alert_placeholder .alert span").text(message);
+    }
+
+    $(function () {
+        $("#loginForm").on("submit", function (event) {
+            event.preventDefault();
+            $.ajax({
+                url: "/login/authenticate",
+                type: "POST",
+                data: {
+                    firstName: $("#firstname").val(),
+                    lastName: $("#lastname").val(),
+                    email: $("#email").val(),
+                    password: $("#password").val()
+                },
+                dataType: "json"
+            }).done(function (body) {
+                var email = body.profileProperties && 
body.profileProperties.email;
+                show(true, "OK — profileId=" + body.profileId
+                    + (email ? (", email=" + email) : "")
+                    + ". Clear context-profile-id and login again with the 
same email to verify merge.");
+            }).fail(function (xhr) {
+                var body = xhr.responseJSON || {};
+                var msg = body.error || body.errorMessage || xhr.responseText 
|| ("HTTP " + xhr.status);
+                show(false, msg);
+            });
+            return false;
+        });
+    });
+})();
diff --git a/samples/login-integration/src/main/webapp/WEB-INF/web.xml 
b/samples/login-integration/src/main/webapp/WEB-INF/web.xml
deleted file mode 100644
index dc145f299..000000000
--- a/samples/login-integration/src/main/webapp/WEB-INF/web.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-  ~ 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.
-  -->
-<web-app xmlns="http://java.sun.com/xml/ns/javaee";
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
-         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd";
-         version="3.0">
-    <welcome-file-list>
-        <welcome-file>index.html</welcome-file>
-    </welcome-file-list>
-</web-app>
\ No newline at end of file
diff --git a/samples/login-integration/src/main/webapp/index.html 
b/samples/login-integration/src/main/webapp/index.html
deleted file mode 100644
index 8b03cbded..000000000
--- a/samples/login-integration/src/main/webapp/index.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<!--
-~ 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.
--->
-<html>
-<head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Login integration example</title>
-    <!-- Latest compiled and minified CSS -->
-    <link rel="stylesheet" 
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css";
-          
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
 crossorigin="anonymous">
-    <!-- Optional theme -->
-    <link rel="stylesheet" 
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css";
-          
integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp"
 crossorigin="anonymous">
-    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
-    <script 
src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js";></script>
-    <!-- Latest compiled and minified JavaScript -->
-    <script 
src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js";
-            
integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
-            crossorigin="anonymous"></script>
-    <script src="javascript/login-example.js"></script>
-</head>
-<body>
-<div class="container">
-    <h1>Login integration example</h1>
-    <p>This is a small example of integrating Apache Unomi with an event login 
in order to merge profile based on emails
-    as merge keys (see associated rule file in 
src/main/resources/META-INF/cxs/rules/exampleLogin.json).</p>
-    <p>Important: note that login events should normally always be sent from 
the server performing the login, not through
-    Javascript for security reasons. Here we provide this type of example only 
for brievety and clarity.</p>
-    <div id="alert_placeholder"></div>
-    <form id="loginForm">
-        <div class="form-group">
-            <label for="firstname">First name</label>
-            <input type="text" name="firstName" id="firstname" 
class="form-control"
-                   placeholder="Enter your first name here"/>
-        </div>
-        <div class="form-group">
-            <label for="lastname">Last name</label>
-            <input type="text" name="lastName" id="lastname" 
class="form-control"
-                   placeholder="Enter your last name here"/>
-        </div>
-        <div class="form-group">
-            <label for="email">Email</label>
-            <input type="text" name="email" id="email" class="form-control" 
placeholder="Enter your email here"
-            >
-        </div>
-        <div class="form-group">
-            <label for="email">Password</label>
-            <input type="password" name="password" id="password" 
class="form-control"
-                   placeholder="Enter your password here">
-        </div>
-        <button id="loginButton" type="submit" class="btn 
btn-default">Login</button>
-    </form>
-</div>
-</body>
-</html>
diff --git 
a/samples/login-integration/src/main/webapp/javascript/login-example.js 
b/samples/login-integration/src/main/webapp/javascript/login-example.js
deleted file mode 100644
index 2704ac853..000000000
--- a/samples/login-integration/src/main/webapp/javascript/login-example.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * 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.
- */
-
-(function () {
-
-    // We use this method to generate unique sessions IDs
-    function generateGuid() {
-        function s4() {
-            var array = new Uint16Array(1);
-            window.crypto.getRandomValues(array);
-            return array[0].toString(16).padStart(4, '0');
-        }
-
-        return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
-            s4() + '-' + s4() + s4() + s4();
-    }
-
-    // -- COOKIE HELPER METHODS ---
-
-    function createCookie(name, value, days) {
-        var expires;
-
-        if (days) {
-            var date = new Date();
-            date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
-            expires = "; expires=" + date.toGMTString();
-        } else {
-            expires = "";
-        }
-        document.cookie = encodeURIComponent(name) + "=" + 
encodeURIComponent(value) + expires + "; path=/";
-    }
-
-    function readCookie(name) {
-        var nameEQ = encodeURIComponent(name) + "=";
-        var ca = document.cookie.split(';');
-        for (var i = 0; i < ca.length; i++) {
-            var c = ca[i];
-            while (c.charAt(0) === ' ') c = c.substring(1, c.length);
-            if (c.indexOf(nameEQ) === 0) return 
decodeURIComponent(c.substring(nameEQ.length, c.length));
-        }
-        return null;
-    }
-
-    function eraseCookie(name) {
-        createCookie(name, "", -1);
-    }
-
-    // -- BOOTSTRAP HELPER METHODS ---
-
-    bootstrapAlert = {};
-    bootstrapAlert.success = function (message) {
-        $('#alert_placeholder').html('<div class="alert alert-success"><a 
class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>')
-    };
-    bootstrapAlert.danger = function (message) {
-        $('#alert_placeholder').html('<div class="alert alert-danger"><a 
class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>')
-    };
-
-    $(document).ready(function () {
-
-        // first we check if we have an existing session ID cookie, if not we 
generate a new session identifier and
-        // store it in the cookie.
-        var unomiSessionId = readCookie('unomi-session-id');
-        if (!unomiSessionId) {
-            unomiSessionId = generateGuid();
-            console.log("No existing session cookie found, creating a new one 
with value " + unomiSessionId);
-            createCookie('unomi-session-id', unomiSessionId, 1);
-        }
-        console.log("Setting up form listener...");
-        $("#loginForm").submit(function (event) {
-            var email = $('#email').val();
-            var firstName = $('#firstname').val();
-            var lastName = $('#lastname').val();
-            var password = $('#password').val();
-            if (password != 'test1234') {
-                bootstrapAlert.danger("Wrong password (default is : 
test1234)");
-                event.preventDefault();
-                return false;
-            }
-            var contextRequest = {
-                source: { // the source is required for the request to be 
process properly
-                    itemId: location.pathname,
-                    itemType: 'webpage',
-                    scope: 'test' // the scope is used to regroup events and 
sessions into sub-groups (eg sites)
-                },
-                events: [{ // here we provide a simple login event, but as 
this is actually an array we could provide other events at the same time (page 
view, clicks, mouse movements, ...)
-                    eventType: "login",
-                    properties: {},
-                    target: {
-                        itemId: email,
-                        itemType: "exampleUser",
-                        properties: {
-                            preferredLanguage: "en",
-                            email: email,
-                            firstName: firstName,
-                            lastName: lastName
-                        }
-                    }
-                }],
-                requiredProfileProperties: ['*'], // this tells Unomi to send 
us back all the profile properties (by default none are returned)
-                requiredSessionProperties: ['*']  // this tells Unomi to send 
us back all the session properties (by default none are returned)
-            };
-            // now let's perform the actual call to Apache Unomi, asking it to 
process the events and give us back the updated (or created) profile.
-            // as we have a rule listening to a login event, it will be 
executed and its actions will be processed.
-            $.ajax({
-                url: "http://localhost:8181/cxs/context.json?sessionId="; + 
unomiSessionId,
-                type: 'POST',
-                data: JSON.stringify(contextRequest), // make sure you sent 
JSON and not form-encoded, otherwise Unomi will generate an error
-                contentType: 'application/json; charset=utf-8',
-                dataType: 'json',
-                async: false,
-                headers : {
-                    'X-Unomi-Api-Key' : '670c26d1cc413346c3b2fd9ce65dab41' // 
this is configured in the etc/org.apache.unomi.thirdparty.cfg
-                },
-                success: function (data) {
-                    console.log("Unomi response:", data);
-                    bootstrapAlert.success("Successfully sent login event to 
Apache Unomi ! (profileId=" + data.profileId + ",properties.email=" + 
data.profileProperties.email + ",nbOfVisits=" + 
data.profileProperties.nbOfVisits + ")");
-                }
-            });
-            event.preventDefault();
-            return false;
-        });
-    });
-
-})();
-
diff --git 
a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
 
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
new file mode 100644
index 000000000..9a220fdc4
--- /dev/null
+++ 
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
@@ -0,0 +1,353 @@
+/*
+ * 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.samples.login;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+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.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atMostOnce;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the security-relevant helpers of {@link LoginServlet}.
+ * <p>
+ * The methods under test are package-private (rather than private) purely so 
these tests can call
+ * them directly instead of reaching through reflection; they are not part of 
any public API.
+ */
+class LoginServletTest {
+
+    // 
------------------------------------------------------------------------------------------
+    // resolveSessionId — the trust boundary. A client-supplied session id 
must never be honoured.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("regression guard: a client-supplied sessionId parameter is 
ignored entirely")
+    void clientSuppliedSessionIdParameterIsIgnored() {
+        String publicCallerSuppliedId = "other-session-id-we-want-to-reuse";
+        HttpSession session = statefulSession();
+        HttpServletRequest req = requestWithSession(session);
+        // Simulate every channel an untrusted caller controls: query/form 
parameters and headers.
+        when(req.getParameter(anyString())).thenReturn(publicCallerSuppliedId);
+        when(req.getHeader(anyString())).thenReturn(publicCallerSuppliedId);
+
+        String resolved = LoginServlet.resolveSessionId(req);
+
+        assertNotEquals(publicCallerSuppliedId, resolved,
+                "the servlet must not adopt a session id supplied by the 
caller");
+        // Stronger than comparing values: prove the request's untrusted 
surface is never
+        // even consulted, so no future refactor can quietly reintroduce the 
vulnerability.
+        verify(req, never()).getParameter(anyString());
+        verify(req, never()).getParameterValues(anyString());
+        verify(req, never()).getHeader(anyString());
+        verify(req, never()).getCookies();
+    }
+
+    @Test
+    @DisplayName("the generated session id is a server-side random UUID stored 
on the container session")
+    void generatedSessionIdIsARandomUuidStoredOnTheSession() {
+        HttpSession session = statefulSession();
+
+        String resolved = 
LoginServlet.resolveSessionId(requestWithSession(session));
+
+        assertNotNull(resolved);
+        assertDoesNotThrow(() -> UUID.fromString(resolved), "expected a random 
UUID, got: " + resolved);
+        assertEquals(resolved, 
session.getAttribute("org.apache.unomi.samples.login.unomiSessionId"),
+                "the resolved id must be the one persisted on the container 
session");
+    }
+
+    @Test
+    @DisplayName("the same browser session yields a stable session id across 
calls")
+    void sameSessionYieldsStableSessionId() {
+        HttpSession session = statefulSession();
+
+        String first = 
LoginServlet.resolveSessionId(requestWithSession(session));
+        String second = 
LoginServlet.resolveSessionId(requestWithSession(session));
+        String third = 
LoginServlet.resolveSessionId(requestWithSession(session));
+
+        assertEquals(first, second);
+        assertEquals(first, third);
+    }
+
+    @Test
+    @DisplayName("two different browser sessions yield different session ids")
+    void differentSessionsYieldDifferentSessionIds() {
+        String first = 
LoginServlet.resolveSessionId(requestWithSession(statefulSession()));
+        String second = 
LoginServlet.resolveSessionId(requestWithSession(statefulSession()));
+
+        assertNotEquals(first, second);
+    }
+
+    @Test
+    @DisplayName("sessions created by this servlet get a short idle timeout so 
they cannot accumulate")
+    void createdSessionsAreGivenAShortIdleTimeout() {
+        HttpSession session = statefulSession();
+
+        LoginServlet.resolveSessionId(requestWithSession(session));
+
+        verify(session).setMaxInactiveInterval(intThatIsAShortTimeout());
+    }
+
+    @Test
+    @DisplayName("the idle timeout is applied only when the id is first 
created, not on every request")
+    void idleTimeoutIsAppliedOnlyOnFirstUse() {
+        HttpSession session = statefulSession();
+
+        LoginServlet.resolveSessionId(requestWithSession(session));
+        LoginServlet.resolveSessionId(requestWithSession(session));
+        LoginServlet.resolveSessionId(requestWithSession(session));
+
+        verify(session, atMostOnce()).setMaxInactiveInterval(anyInt());
+    }
+
+    @Test
+    @DisplayName("an existing container session is reused rather than 
replaced")
+    void existingSessionIsReused() {
+        HttpSession session = statefulSession();
+        HttpServletRequest req = requestWithSession(session);
+
+        LoginServlet.resolveSessionId(req);
+
+        // getSession(true) is correct: the servlet needs a session to exist. 
What must not happen is
+        // the servlet inventing a second identity source.
+        verify(req, times(1)).getSession(anyBoolean());
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // isSameOrigin — lightweight CSRF defence.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("a matching origin is accepted")
+    void sameOriginIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("an http origin with no explicit port matches port 80")
+    void defaultHttpPortIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com";, "http", "example.com", 
80)));
+    }
+
+    @Test
+    @DisplayName("an https origin with no explicit port matches port 443")
+    void defaultHttpsPortIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("https://example.com";, "https", 
"example.com", 443)));
+    }
+
+    @Test
+    @DisplayName("origin comparison is case-insensitive on scheme and host")
+    void originComparisonIsCaseInsensitive() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("HTTP://Example.COM:8181", "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a different host is rejected")
+    void differentHostIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://evil.example.net:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a different port is rejected")
+    void differentPortIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com:9090";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("an implicit default port that does not match the served port 
is rejected")
+    void implicitDefaultPortMismatchIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com";, "http", "example.com", 
8181)));
+    }
+
+    @Test
+    @DisplayName("a different scheme is rejected")
+    void differentSchemeIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("https://example.com:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a missing Origin header is tolerated")
+    void missingOriginIsTolerated() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin(null, "http", "example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a blank Origin header is tolerated")
+    void blankOriginIsTolerated() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("   ", "http", "example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("the opaque \"null\" origin sent by sandboxed frames is 
rejected")
+    void opaqueNullOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("null", "http", "example.com", 8181)),
+                "the literal string \"null\" is an opaque origin, not a 
missing header");
+    }
+
+    @Test
+    @DisplayName("an unparsable Origin header is rejected")
+    void unparsableOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://exa mple.com", "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a syntactically valid but host-less Origin is rejected")
+    void hostlessOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("file:///etc/passwd", "http", "example.com", 
8181)));
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // headerValues — multi-value, case-insensitive Set-Cookie forwarding.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("headerValues returns every value of a repeated header")
+    void headerValuesReturnsAllValues() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        headers.put("Set-Cookie", Arrays.asList("context-profile-id=p1; 
Path=/", "context-session-id=s1; Path=/"));
+
+        List<String> values = 
LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie");
+
+        assertEquals(Arrays.asList("context-profile-id=p1; Path=/", 
"context-session-id=s1; Path=/"), values);
+    }
+
+    @Test
+    @DisplayName("headerValues matches the header name case-insensitively")
+    void headerValuesMatchesNameCaseInsensitively() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        // Servers are free to use any casing; HttpURLConnection preserves 
what came off the wire.
+        headers.put("set-cookie", new ArrayList<>(Arrays.asList("a=1", 
"b=2")));
+
+        List<String> values = 
LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie");
+
+        assertEquals(Arrays.asList("a=1", "b=2"), values);
+    }
+
+    @Test
+    @DisplayName("headerValues tolerates the null status-line key and returns 
null for an absent header")
+    void headerValuesReturnsNullWhenAbsent() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        // HttpURLConnection.getHeaderFields() maps the HTTP status line under 
a null key.
+        headers.put(null, Arrays.asList("HTTP/1.1 200 OK"));
+        headers.put("Content-Type", Arrays.asList("application/json"));
+
+        assertNull(LoginServlet.headerValues(connectionWithHeaders(headers), 
"Set-Cookie"));
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Fakes / helpers
+    // 
------------------------------------------------------------------------------------------
+
+    /** A mock {@link HttpSession} with real attribute storage, so id 
stability can be observed. */
+    private static HttpSession statefulSession() {
+        HttpSession session = mock(HttpSession.class);
+        Map<String, Object> attributes = new HashMap<>();
+        when(session.getAttribute(anyString())).thenAnswer(inv -> 
attributes.get(inv.<String>getArgument(0)));
+        doAnswer(inv -> {
+            attributes.put(inv.getArgument(0), inv.getArgument(1));
+            return null;
+        }).when(session).setAttribute(anyString(), any());
+        return session;
+    }
+
+    private static HttpServletRequest requestWithSession(HttpSession session) {
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getSession(anyBoolean())).thenReturn(session);
+        return req;
+    }
+
+    private static HttpServletRequest requestWithOrigin(String origin, String 
scheme, String serverName, int port) {
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getHeader("Origin")).thenReturn(origin);
+        when(req.getScheme()).thenReturn(scheme);
+        when(req.getServerName()).thenReturn(serverName);
+        when(req.getServerPort()).thenReturn(port);
+        return req;
+    }
+
+    private static HttpURLConnection connectionWithHeaders(Map<String, 
List<String>> headers) throws Exception {
+        return new HttpURLConnection(new 
URL("http://localhost:8181/cxs/context.json";)) {
+            @Override
+            public Map<String, List<String>> getHeaderFields() {
+                return headers;
+            }
+
+            @Override
+            public void connect() {
+                // never actually connects
+            }
+
+            @Override
+            public void disconnect() {
+                // nothing to release
+            }
+
+            @Override
+            public boolean usingProxy() {
+                return false;
+            }
+        };
+    }
+
+    /**
+     * Matches any timeout that is positive and no longer than ten minutes: 
the exact value is a
+     * tuning detail, but "short and bounded" is the security property we care 
about.
+     */
+    private static int intThatIsAShortTimeout() {
+        return org.mockito.ArgumentMatchers.intThat(seconds -> seconds > 0 && 
seconds <= 600);
+    }
+}

Reply via email to