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

asf-gitbox-commits pushed a commit to branch UNOMI-974-explicit-admin-password
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit a7b2488ed39842d7511442f4309ca8709dc57893
Author: Serge Huber <[email protected]>
AuthorDate: Thu Aug 13 14:43:46 2026 +0200

    UNOMI-974: require an explicit admin and health-check password at startup
    
    The shipped karaf and health-check accounts fell back to a value carried in 
the distribution when
    the operator set nothing. Both now resolve only from UNOMI_ROOT_PASSWORD and
    UNOMI_HEALTHCHECK_PASSWORD, with no fallback.
    
    Removing the fallback is necessary but not sufficient, because of how Karaf 
resolves properties:
    PropertiesLoader and PropertiesLoginModule both substitute with 
defaultsToEmptyString=true, so an
    unset property yields an empty value rather than an unusable account. The 
account must therefore be
    made unusable deliberately. bin/setenv and the Docker entrypoint refuse to 
start when either
    variable is unset, and AuthenticationFilter requires a non-blank password 
on any Basic credential it
    accepts.
    
    That last check runs at each point a Basic credential is consumed rather 
than once at the top of
    filter(). The public paths and every V2 path ignore Authorization entirely, 
so a single up-front
    check would turn a stray or stale header into a 401 on requests that must 
succeed anonymously.
    
    The shell guards cannot cover every way the JVM is started - karaf.bat 
calls setenv.bat without
    testing errorlevel, as setenv itself documents, and a systemd unit or 
container command override
    skips them too - so the REST-layer requirement is what actually holds. The 
guards are executed by
    the tests rather than grepped, because a check whose text is present but 
whose condition never
    matches would otherwise pass silently.
    
    Documentation, examples, compose files and the setup scripts no longer 
carry a sample password.
    Operators upgrading must set both variables before starting; the 3.0-to-3.1 
migration guide covers
    it.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 clear-elasticsearch.sh                             |   2 +
 clear-opensearch.sh                                |   2 +
 docker/README.md                                   |  18 +-
 docker/src/main/docker/docker-compose-build-es.yml |   2 +
 docker/src/main/docker/docker-compose-build-os.yml |   2 +
 docker/src/main/docker/docker-compose-cluster.yml  |   4 +
 docker/src/main/docker/docker-compose-es.yml       |   3 +
 docker/src/main/docker/docker-compose-os.yml       |   3 +
 docker/src/main/docker/entrypoint.sh               |  41 ++
 .../test/java/org/apache/unomi/itests/BaseIT.java  |   6 +
 .../test/java/org/apache/unomi/itests/BasicIT.java |  29 +-
 .../org/apache/unomi/itests/HealthCheckIT.java     |  11 +-
 .../java/org/apache/unomi/itests/TenantIT.java     |   8 +-
 .../apache/unomi/itests/V2CompatibilityModeIT.java |   4 +-
 .../apache/unomi/itests/graphql/BaseGraphQLIT.java |   2 +-
 itests/src/test/resources/etc/users.properties     |   4 +-
 manual/src/main/asciidoc/5-min-quickstart.adoc     |  38 +-
 manual/src/main/asciidoc/configuration.adoc        |  62 +--
 .../asciidoc/connectors/salesforce-connector.adoc  |  11 +-
 manual/src/main/asciidoc/getting-started.adoc      |  25 +-
 manual/src/main/asciidoc/graphql-examples.adoc     |   9 +-
 .../main/asciidoc/jsonSchema/json-schema-api.adoc  |   2 +-
 .../asciidoc/migrations/migrate-3.0-to-3.1.adoc    | 112 +++++-
 .../src/main/asciidoc/migrations/migrations.adoc   |   2 +-
 .../asciidoc/migrations/v2-compatibility-mode.adoc |   6 +-
 .../asciidoc/migrations/v2-v3-compatibility.adoc   |  24 +-
 manual/src/main/asciidoc/scheduler.adoc            |  16 +-
 manual/src/main/asciidoc/security.adoc             |   4 +-
 manual/src/main/asciidoc/shell-commands.adoc       |   2 +-
 manual/src/main/asciidoc/tutorial.adoc             |   4 +-
 manual/src/main/asciidoc/whats-new.adoc            |  10 +-
 package/src/main/resources/bin/setenv              |  88 +++++
 package/src/main/resources/bin/setenv.bat          |  61 +++
 .../main/resources/etc/custom.system.properties    |   8 +-
 package/src/main/resources/etc/users.properties    |   6 +-
 .../rest/authentication/AuthenticationFilter.java  |  76 +++-
 .../AuthenticationFilterBlankPasswordTest.java     | 243 ++++++++++++
 .../config/ShippedAdminPasswordConfigTest.java     | 418 +++++++++++++++++++++
 setup-elasticsearch.sh                             |  22 +-
 setup-opensearch.sh                                |  26 +-
 setup-utils.sh                                     |  27 ++
 41 files changed, 1299 insertions(+), 144 deletions(-)

diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh
index 0d92e9da2..baa760647 100755
--- a/clear-elasticsearch.sh
+++ b/clear-elasticsearch.sh
@@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE
 unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES
 # Also set by setup-elasticsearch.sh / setup-opensearch.sh
 unset UNOMI_DISTRIBUTION
+unset UNOMI_ROOT_PASSWORD
+unset UNOMI_HEALTHCHECK_PASSWORD
 
 unset _IS_SOURCED
 
diff --git a/clear-opensearch.sh b/clear-opensearch.sh
index 61b6c5a12..df4c9425b 100755
--- a/clear-opensearch.sh
+++ b/clear-opensearch.sh
@@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE
 unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES
 # Also set by setup-opensearch.sh / setup-elasticsearch.sh
 unset UNOMI_DISTRIBUTION
+unset UNOMI_ROOT_PASSWORD
+unset UNOMI_HEALTHCHECK_PASSWORD
 
 unset _IS_SOURCED
 
diff --git a/docker/README.md b/docker/README.md
index c5ab6553d..a5dc49bde 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -30,9 +30,13 @@ required Unomi tarball.
 
 ## Launching docker-compose using Maven project
 
-Unomi requires a search engine (ElasticSearch or OpenSearch) so it is 
recommended to run Unomi and the search engine using docker-compose:
+Unomi requires a search engine (ElasticSearch or OpenSearch) so it is 
recommended to run Unomi and the search engine using docker-compose.
+
+Set admin and health passwords first (required; no known defaults are shipped):
 
 ```
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
 mvn docker:start
 ```
 
@@ -72,6 +76,8 @@ For Unomi (with ElasticSearch):
 ```bash
 docker pull apache/unomi:3.1.0-SNAPSHOT
 docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+    -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+    -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
     -e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \
     apache/unomi:3.1.0-SNAPSHOT
 ```
@@ -81,6 +87,8 @@ For Unomi (with OpenSearch):
 ```bash
 docker pull apache/unomi:3.1.0-SNAPSHOT
 docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+    -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+    -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
     -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
     -e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \
     -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
@@ -93,6 +101,8 @@ For ElasticSearch:
 
 ```bash
 docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+    -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+    -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
     -e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \
     apache/unomi:3.1.0-SNAPSHOT
 ```
@@ -101,6 +111,8 @@ For OpenSearch:
 
 ```bash
 docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+    -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+    -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
     -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
     -e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \
     -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
@@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS 
lookup method yet, it s
 ## Environment Variables
 
 ### Common Variables
+- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default
+- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no 
known default
 - `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf 
(defaults to `true`)
 - `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use 
(`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, 
defaults to `unomi-distribution-elasticsearch`)
 
@@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints 
such as `/cxs/context.js
 
 ```bash
 curl -X POST http://localhost:8181/cxs/tenants \
-  --user karaf:karaf \
+  --user "karaf:${UNOMI_ROOT_PASSWORD}" \
   -H "Content-Type: application/json" \
   -d '{"requestedId":"default","properties":{"name":"Default Tenant"}}'
 ```
diff --git a/docker/src/main/docker/docker-compose-build-es.yml 
b/docker/src/main/docker/docker-compose-build-es.yml
index 03f265a7b..34193ad30 100644
--- a/docker/src/main/docker/docker-compose-build-es.yml
+++ b/docker/src/main/docker/docker-compose-build-es.yml
@@ -39,6 +39,8 @@ services:
       - UNOMI_AUTO_START=true
       - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
       - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
       # Debug settings
       - KARAF_DEBUG=${DEBUG:-false}
       - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-build-os.yml 
b/docker/src/main/docker/docker-compose-build-os.yml
index 97d38cfe4..f5c8592d5 100644
--- a/docker/src/main/docker/docker-compose-build-os.yml
+++ b/docker/src/main/docker/docker-compose-build-os.yml
@@ -100,6 +100,8 @@ services:
       - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
       - UNOMI_OPENSEARCH_USERNAME=admin
       - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
       # Debug settings
       - KARAF_DEBUG=${DEBUG:-false}
       - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-cluster.yml 
b/docker/src/main/docker/docker-compose-cluster.yml
index 75aed4628..fa1a3069e 100644
--- a/docker/src/main/docker/docker-compose-cluster.yml
+++ b/docker/src/main/docker/docker-compose-cluster.yml
@@ -36,6 +36,8 @@ services:
     environment:
       - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
       - UNOMI_CLUSTER_NODEID=unomi-3-node-1
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
     ports:
       - 8181:8181
       - 9443:9443
@@ -58,6 +60,8 @@ services:
     environment:
       - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
       - UNOMI_CLUSTER_NODEID=unomi-3-node-2
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
     ports:
       - 8182:8181
       - 9444:9443
diff --git a/docker/src/main/docker/docker-compose-es.yml 
b/docker/src/main/docker/docker-compose-es.yml
index edfada591..a3d294672 100644
--- a/docker/src/main/docker/docker-compose-es.yml
+++ b/docker/src/main/docker/docker-compose-es.yml
@@ -45,6 +45,9 @@ services:
       - UNOMI_AUTO_START=true
       - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
       - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
+      # Required admin password (no known default is shipped). Override via 
.env / shell.
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
       # Debug settings
       - KARAF_DEBUG=${DEBUG:-false}
       - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-os.yml 
b/docker/src/main/docker/docker-compose-os.yml
index 579d3e2bc..a37babf5b 100644
--- a/docker/src/main/docker/docker-compose-os.yml
+++ b/docker/src/main/docker/docker-compose-os.yml
@@ -84,6 +84,9 @@ services:
       - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
       - UNOMI_OPENSEARCH_USERNAME=admin
       - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
+      # Required admin password (no known default is shipped). Override via 
.env / shell.
+      - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+      - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set 
UNOMI_HEALTHCHECK_PASSWORD}
       # Debug settings
       - KARAF_DEBUG=${DEBUG:-false}
       - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/entrypoint.sh 
b/docker/src/main/docker/entrypoint.sh
index 1cfc68456..e805537b4 100755
--- a/docker/src/main/docker/entrypoint.sh
+++ b/docker/src/main/docker/entrypoint.sh
@@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} 
-Dunomi.distribution=${
 
 echo "KARAF_OPTS: $KARAF_OPTS"
 
+# Refuse to start without admin/health passwords. An unset password is not "no 
account": it
+# expands to the empty string, which Karaf's PropertiesLoginModule accepts as 
a valid password.
+# This is the gate for container launches: exiting here means the container 
fails to start rather
+# than booting with an administrator account that accepts an empty password.
+check_required_password() {
+    # $1 env var name, $2 property name, $3 skip flag name
+    eval _value=\"\${$1}\"
+    eval _skip=\"\${$3}\"
+
+    [ -n "${_value}" ] && return 0
+
+    if [ "${_skip}" = "true" ]; then
+        cat >&2 <<EOF
+
+WARNING: $3=true but $1 is empty.
+         Unless $2 is supplied another way, the
+         account will have an EMPTY password that grants full administrator 
access.
+
+EOF
+        return 0
+    fi
+
+    cat >&2 <<EOF
+ERROR: $1 is not set.
+
+Apache Unomi does not ship a known default password, and an unset value 
becomes an EMPTY
+password that still authenticates. Pass it when starting the container, for 
example:
+
+  docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \\
+             -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' 
...
+
+Or with docker compose, export both UNOMI_ROOT_PASSWORD and 
UNOMI_HEALTHCHECK_PASSWORD first.
+EOF
+    return 1
+}
+
+check_required_password UNOMI_ROOT_PASSWORD \
+    org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || 
exit 1
+check_required_password UNOMI_HEALTHCHECK_PASSWORD \
+    org.apache.unomi.healthcheck.password 
UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1
+
 # Function to check cluster health for a specific node
 check_node_health() {
     local node_url="$1"
diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java 
b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
index 5ec6385ba..1940d3c79 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -132,7 +132,10 @@ public abstract class BaseIT extends KarafTestSupport {
     protected static final ContentType JSON_CONTENT_TYPE = 
ContentType.create("application/json");
     protected static final String BASE_URL = "http://localhost";;
     protected static final String BASIC_AUTH_USER_NAME = "karaf";
+    /** Explicit IT password — package no longer ships a known default ({@code 
UNOMI_ROOT_PASSWORD}). */
     protected static final String BASIC_AUTH_PASSWORD = "karaf";
+    protected static final String HEALTHCHECK_AUTH_USER_NAME = "health";
+    protected static final String HEALTHCHECK_AUTH_PASSWORD = "health";
     protected static final int REQUEST_TIMEOUT = 60000;
     protected static final int DEFAULT_TRYING_TIMEOUT = 1000;
     protected static final int DEFAULT_TRYING_TRIES = 10;
@@ -671,6 +674,9 @@ public abstract class BaseIT extends KarafTestSupport {
                 editConfigurationFilePut("etc/system.properties", 
SEARCH_ENGINE_PROPERTY, searchEngine),
                 editConfigurationFilePut("etc/system.properties", 
PERSISTENCE_PROVIDER_PROPERTY, searchEngine),
                 editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.migration.tenant.id", TEST_TENANT_ID),
+                // Explicit test credentials (package no longer ships a known 
default password).
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD),
+                editConfigurationFilePut("etc/custom.system.properties", 
"org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD),
                 // Default scheduler.thread.poolSize (5) is sized for the 
near-instant in-memory unit-test
                 // double, not a real ES/OS backend. Under real refresh/write 
latency, the checker, task
                 // executions, and lease-renewal heartbeats (see 
scheduler.adoc) compete for the same small
diff --git a/itests/src/test/java/org/apache/unomi/itests/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/HealthCheckIT.java 
b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
index 305c2f38e..f7179a7f4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -54,8 +54,6 @@ public class HealthCheckIT extends BaseIT {
 
     private final static Logger LOGGER = 
LoggerFactory.getLogger(HealthCheckIT.class);
 
-    protected static final String HEALTHCHECK_AUTH_USER_NAME = "health";
-    protected static final String HEALTHCHECK_AUTH_PASSWORD = "health";
     protected static final String HEALTHCHECK_ENDPOINT = "/health/check";
 
     @Test
@@ -132,6 +130,15 @@ public class HealthCheckIT extends BaseIT {
         }
     }
 
+    @Test
+    public void testHealthCheck_wrongPasswordRejected() throws Exception {
+        final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT));
+        try (CloseableHttpResponse response = executeHttpRequest(
+                httpGet, AuthType.CUSTOM_BASIC, HEALTHCHECK_AUTH_USER_NAME, 
"wrong-password")) {
+            Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+        }
+    }
+
     protected <T> T get(final String url, TypeReference<T> typeReference) {
         CloseableHttpResponse response = null;
         try {
diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java 
b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
index a9ef9a595..9cce84707 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
@@ -190,7 +190,7 @@ public class TenantIT extends BaseIT {
 
         // Create test tenant for API key tests
         BasicCredentialsProvider adminCredsProvider = new 
BasicCredentialsProvider();
-        adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+        adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
 
         try (CloseableHttpClient adminClient = 
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build()) 
{
             Map<String, Object> requestBody = new HashMap<>();
@@ -273,7 +273,7 @@ public class TenantIT extends BaseIT {
 
             // Test with JAAS auth (should succeed) — use a fresh request to 
avoid carrying X-Unomi-Api-Key from previous step
             BasicCredentialsProvider adminCredsProvider = new 
BasicCredentialsProvider();
-            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
             try (CloseableHttpClient adminClient = 
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
                  CloseableHttpResponse response = adminClient.execute(new 
HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) {
                 Assert.assertEquals("JAAS auth should grant access to public 
endpoints", 200, response.getStatusLine().getStatusCode());
@@ -325,7 +325,7 @@ public class TenantIT extends BaseIT {
 
             // Test with JAAS auth (should succeed) — use a fresh request to 
avoid carrying Authorization from previous step
             BasicCredentialsProvider adminCredsProvider = new 
BasicCredentialsProvider();
-            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
             try (CloseableHttpClient adminClient = 
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
                  CloseableHttpResponse response = adminClient.execute(new 
HttpGet(getFullUrl("/cxs/profiles/count")))) {
                 Assert.assertEquals("JAAS auth should grant access to private 
endpoints", 200, response.getStatusLine().getStatusCode());
@@ -378,7 +378,7 @@ public class TenantIT extends BaseIT {
 
             // Test with JAAS authentication (should succeed)
             getRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
-            getRequest.setHeader("Authorization", "Basic " + 
Base64.getEncoder().encodeToString(("karaf:karaf").getBytes()));
+            getRequest.setHeader("Authorization", "Basic " + 
Base64.getEncoder().encodeToString((BASIC_AUTH_USER_NAME + ":" + 
BASIC_AUTH_PASSWORD).getBytes()));
             try (CloseableHttpResponse response = 
executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) {
                 Assert.assertEquals("JAAS authentication should grant access 
to private endpoints", 200, response.getStatusLine().getStatusCode());
             }
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java 
b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
index cce6c5484..f27089368 100644
--- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -225,7 +225,7 @@ public class V2CompatibilityModeIT extends BaseIT {
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
 
         BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
-        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
 
         RequestConfig requestConfig = RequestConfig.custom()
                 .setAuthenticationEnabled(true)
@@ -283,7 +283,7 @@ public class V2CompatibilityModeIT extends BaseIT {
         HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + 
TEST_PROFILE_ID));
 
         BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
-        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
 
         RequestConfig requestConfig = RequestConfig.custom()
                 .setAuthenticationEnabled(true)
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java 
b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
index 1ff220109..34db0381e 100644
--- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
@@ -72,7 +72,7 @@ public abstract class BaseGraphQLIT extends BaseIT {
     }
 
     /**
-     * Performs a GraphQL POST request with JAAS admin authentication 
(karaf:karaf).
+     * Performs a GraphQL POST request with JAAS admin authentication ({@link 
#BASIC_AUTH_USER_NAME}/{@link #BASIC_AUTH_PASSWORD}).
      * This is equivalent to AuthType.JAAS_ADMIN.
      *
      * @param resource The resource path to the GraphQL query/mutation
diff --git a/itests/src/test/resources/etc/users.properties 
b/itests/src/test/resources/etc/users.properties
index 377fe6a16..538906c38 100644
--- a/itests/src/test/resources/etc/users.properties
+++ b/itests/src/test/resources/etc/users.properties
@@ -29,6 +29,6 @@
 # and modifiable via the JAAS command group. These users reside in a JAAS 
domain
 # with the name "karaf".
 #
-karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup
-health = ${org.apache.unomi.healthcheck.password:-health},health
+karaf = ${org.apache.unomi.security.root.password},_g_:admingroup
+health = ${org.apache.unomi.healthcheck.password},health
 _g_\:admingroup = 
group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_USER,ROLE_UNOMI_TENANT_ADMIN
diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc 
b/manual/src/main/asciidoc/5-min-quickstart.adoc
index c42a9404a..9966b275a 100644
--- a/manual/src/main/asciidoc/5-min-quickstart.adoc
+++ b/manual/src/main/asciidoc/5-min-quickstart.adoc
@@ -19,6 +19,16 @@ Begin by creating a `docker-compose.yml` file. You can 
choose between Elasticsea
 
 ==== Option 1: Using Elasticsearch
 
+Export the passwords first. The compose files read them from your shell, and 
the `curl` commands
+further down use the same variables, so setting them once here keeps both 
consistent. Unomi refuses
+to start if they are unset.
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
 [source,yaml]
 ----
 version: '3.8'
@@ -35,6 +45,8 @@ services:
         environment:
             - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
             - UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1
+            - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD}
+            - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD}
         ports:
             - 8181:8181
             - 9443:9443
@@ -84,6 +96,8 @@ services:
             - UNOMI_OPENSEARCH_SSL_ENABLE=true
             - UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true
             - UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence
+            - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD}
+            - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD}
         ports:
             - 8181:8181
             - 9443:9443
@@ -104,7 +118,7 @@ Once Unomi is running, create a tenant, then **regenerate** 
API keys and save th
 [source,bash]
 ----
 curl -X POST http://localhost:8181/cxs/tenants \
-  --user karaf:karaf \
+  --user "karaf:${UNOMI_ROOT_PASSWORD}" \
   -H "Content-Type: application/json" \
   -d '{
     "requestedId": "default",
@@ -114,13 +128,13 @@ curl -X POST http://localhost:8181/cxs/tenants \
     }
   }'
 
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
 ----
 
 Use the public API key in `X-Unomi-Api-Key` for `/cxs/context.json` requests. 
See <<_multitenancy,Multi-tenancy>>.
 
-Try accessing https://localhost:9443/cxs/cluster with username/password: 
karaf/karaf . You might get a certificate warning in your browser, just accept 
it despite the warning it is safe.
+Try accessing https://localhost:9443/cxs/cluster with username/password: 
`karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in 
your browser, just accept it despite the warning it is safe.
 
 === Quick Start manually
 
@@ -159,6 +173,14 @@ discovery.type: single-node
 
 5) Download Apache Unomi here : https://unomi.apache.org/download.html
 
+5b) Before starting Karaf, export required passwords (Unomi will refuse to 
start without them):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
 6) Start it using : `./bin/karaf`
 
 7) Start the Apache Unomi packages using:
@@ -173,14 +195,14 @@ which determines which set of features and bundles are 
installed and started. A
 
 8) Wait for startup to complete
 
-9) Try accessing https://localhost:9443/cxs/cluster with username/password: 
`karaf/karaf` . You might get a certificate warning in your browser, just 
accept it despite the warning it is safe.
+9) Try accessing https://localhost:9443/cxs/cluster with username/password: 
`karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in 
your browser, just accept it despite the warning it is safe.
 
 10) Create a tenant that will own all your data, then regenerate keys and 
store `plainTextKey`:
 
 [source,bash]
 ----
 curl -X POST http://localhost:8181/cxs/tenants \
-  --user karaf:karaf \
+  --user "karaf:${UNOMI_ROOT_PASSWORD}" \
   -H "Content-Type: application/json" \
   -d '{
     "requestedId": "default",
@@ -190,8 +212,8 @@ curl -X POST http://localhost:8181/cxs/tenants \
     }
   }'
 
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
 ----
 
 Save the `plainTextKey` values from the key-creation responses — you'll need 
them for API calls.
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index b76b2abb2..2ebbbc88c 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -248,7 +248,10 @@ At the end, you should have about 4 million entries in the 
geonames index.
 === REST API Security
 
 The Apache Unomi Context Server REST API is protected using JAAS 
authentication and using Basic or Digest HTTP auth.
-By default, the login/password for the REST API full administrative access is 
"karaf/karaf".
+You must set an admin password via `UNOMI_ROOT_PASSWORD` (or 
`org.apache.unomi.security.root.password`)
+and a health-check password via `UNOMI_HEALTHCHECK_PASSWORD` (or 
`org.apache.unomi.healthcheck.password`);
+Unomi does not ship known defaults.
+The default JAAS user name is `karaf`.
 
 The generated package is also configured with a default SSL certificate. You 
can change it by following these steps :
 
@@ -267,8 +270,9 @@ 
org.ops4j.pax.web.ssl.keypassword=${env:UNOMI_SSL_KEYPASSWORD:-changeme}
 
 You should now have SSL setup on Karaf with your certificate, and you can test 
it by trying to access it on port 9443.
 
-Changing the default Karaf password can be done by modifying the 
`org.apache.unomi.security.root.password` in the
-`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
+Changing the Karaf admin password is done by setting `UNOMI_ROOT_PASSWORD` or 
by modifying
+`org.apache.unomi.security.root.password` in the
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file. Unomi does not ship 
a known default password.
 
 === Tenant Management and API Access
 
@@ -278,14 +282,14 @@ Apache Unomi supports multi-tenancy, allowing multiple 
organizations to use the
 
 IMPORTANT: All tenant management operations (create, list, update, delete, API 
key management) are restricted to administrators only and require JAAS 
authentication. These endpoints cannot be accessed using tenant API keys.
 
-To manage tenants, you need administrator access to Unomi (default 
credentials: karaf/karaf). You can manage tenants using either the REST API or 
the Karaf shell commands:
+To manage tenants, you need administrator access to Unomi (`karaf` / your 
`UNOMI_ROOT_PASSWORD`). You can manage tenants using either the REST API or the 
Karaf shell commands:
 
 Using REST API (requires admin credentials):
 [source,bash]
 ----
 # Create a new tenant (JAAS auth required)
 curl -X POST "http://localhost:8181/cxs/tenants"; \
-  -u karaf:karaf \
+  -u "karaf:$UNOMI_ROOT_PASSWORD" \
   -H "Content-Type: application/json" \
   -d '{
     "requestedId": "mytenant",
@@ -318,17 +322,17 @@ curl -X POST "http://localhost:8181/cxs/tenants"; \
 
 # List all tenants (JAAS auth required)
 curl -X GET "http://localhost:8181/cxs/tenants"; \
-  -u karaf:karaf \
+  -u "karaf:$UNOMI_ROOT_PASSWORD" \
   -H "Accept: application/json"
 
 # Get tenant details (JAAS auth required)
 curl -X GET "http://localhost:8181/cxs/tenants/mytenant"; \
-  -u karaf:karaf \
+  -u "karaf:$UNOMI_ROOT_PASSWORD" \
   -H "Accept: application/json"
 
 # Delete a tenant (JAAS auth required)
 curl -X DELETE "http://localhost:8181/cxs/tenants/mytenant"; \
-  -u karaf:karaf
+  -u "karaf:$UNOMI_ROOT_PASSWORD"
 ----
 
 Using Karaf shell (requires admin access to Karaf console). See 
<<_shell_commands,Shell commands>> for full syntax:
@@ -362,9 +366,9 @@ unomi:crud read tenant -i mytenant
 
 # Obtain plaintext (store immediately; it is not persisted)
 curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC"; \
-  -u karaf:karaf
+  -u "karaf:$UNOMI_ROOT_PASSWORD"
 curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE"; 
\
-  -u karaf:karaf
+  -u "karaf:$UNOMI_ROOT_PASSWORD"
 
 # Response example:
 {
@@ -382,7 +386,7 @@ To generate new API keys (requires admin access):
 ----
 # Using REST API (JAAS auth required). Replaces any existing key of the same 
type.
 curl -X POST 
"http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC&validityDays=30";
 \
-  -u karaf:karaf
+  -u "karaf:$UNOMI_ROOT_PASSWORD"
 
 # Response (HTTP 200 OK) — store plainTextKey immediately:
 {
@@ -442,7 +446,7 @@ curl -X POST "http://localhost:8181/cxs/profiles/search"; \
 [source,bash]
 ----
 curl -X GET "http://localhost:8181/cxs/tenants"; \
-  --user "karaf:karaf" \
+  --user "karaf:$UNOMI_ROOT_PASSWORD" \
   -H "Accept: application/json"
 ----
 
@@ -817,10 +821,12 @@ Once the action has been created you need to submit it to 
Unomi (from the same f
 [source,bash]
 ----
 curl -X POST 'http://localhost:8181/cxs/groovyActions' \
---user "TENANT_ID:PRIVATE_KEY" \
+--user "karaf:${UNOMI_ROOT_PASSWORD}" \
 --form '[email protected]'
 ----
 
+NOTE: Groovy Actions REST requires the system `ADMINISTRATOR` role (not a 
tenant private key). See <<_client_facing_hardening_3_1,client-facing 
hardening>>.
+
 Important: A bug ( https://issues.apache.org/jira/browse/UNOMI-847[UNOMI-847] 
) in Apache Unomi 2.5 and lower requires the filename of a Groovy file being 
submitted to be the same as the id of the Groovy action (as per the example 
above).
 
 Finally, register a rule to trigger execution of the groovy action:
@@ -860,7 +866,7 @@ Once you're done with the Hello World! action, it can be 
deleted using the follo
 [source,bash]
 ----
 curl -X DELETE 
'http://localhost:8181/cxs/groovyActions/helloWorldGroovyAction' \
---user "TENANT_ID:PRIVATE_KEY"
+--user "karaf:${UNOMI_ROOT_PASSWORD}"
 ----
 
 And the corresponding rule can be deleted using the following command:
@@ -1078,7 +1084,7 @@ The `MergeProfilesOnPropertyAction` supports the 
following parameters:
 
 ==== Security considerations
 
-IMPORTANT: Never trigger profile merges from unauthenticated operations such 
as form submissions or public-facing APIs. Always verify user identity before 
performing a merge.
+IMPORTANT: Never trigger profile merges from unauthenticated or 
public-key-only operations such as form submissions or public-facing context 
events. Merging into another profile (or switching identity after a merge) 
requires a **trusted** caller — system administrator or tenant administrator 
(private key). Always verify user identity on your application server before 
emitting a merge-triggering event. See 
<<_client_facing_hardening_3_1,client-facing hardening>>.
 
 The following diagram highlights key security considerations:
 
@@ -1178,23 +1184,29 @@ documentation for here :
 
 * 
https://karaf.apache.org/manual/latest/#_security_2[https://karaf.apache.org/manual/latest/#_security_2]
 
-The default username/password is
+You must set an admin password and a health-check password before using the 
REST API / health endpoints.
+There are **no** known default passwords shipped with Unomi.
 
-[source]
+Set the environment variables `UNOMI_ROOT_PASSWORD` and 
`UNOMI_HEALTHCHECK_PASSWORD`, or set
+`org.apache.unomi.security.root.password` and 
`org.apache.unomi.healthcheck.password` in
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` (or 
`etc/custom.system.properties`).
+
+Example:
+
+[source,bash]
 ----
-karaf/karaf
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
 ----
 
-You should really change this default username/password as soon as possible. 
Changing the default Karaf password can be
-done by modifying the `org.apache.unomi.security.root.password` in the 
`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
-
-Or if you want to also change the user name you could modify the following 
file :
+The JAAS admin user name defaults to `karaf`. Authenticate with 
`karaf:$UNOMI_ROOT_PASSWORD`.
+The health-check user name defaults to `health`. Authenticate with 
`health:$UNOMI_HEALTHCHECK_PASSWORD`.
 
-    $MY_KARAF_HOME/etc/users.properties
+To change the admin user name, edit `$MY_KARAF_HOME/etc/users.properties` and 
also set:
 
-But you will also need to change the following property in the 
$MY_KARAF_HOME/etc/unomi.custom.system.properties :
+    karaf.local.user = <your-user>
 
-    karaf.local.user = karaf
+in `unomi.custom.system.properties`.
 
 For your context servers, and for any standalone Elasticsearch nodes you will 
need to open the following ports for proper
 node-to-node communication : 9200 (Elasticsearch REST API), 9300 
(Elasticsearch TCP transport)
diff --git a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc 
b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
index 926039747..abe176384 100644
--- a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
+++ b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
@@ -77,7 +77,7 @@ If this is not the case or you prefer to deploy using a KAR 
bundle, see the KAR
 +
 [source]
 ----
-ssh -p 8102 karaf@localhost (default password is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured 
UNOMI_ROOT_PASSWORD)
 ----
 +
 . Deploy into Apache Unomi using the following commands from the Apache Karaf 
shell:
@@ -111,8 +111,9 @@ The first URL will give you information about the version 
of the connectors, so
 plugin is properly deployed, started and the correct version. The second URL 
will actually make a request to the
 Salesforce REST API to retrieve the limits of the Salesforce API.
 +
-Both URLs are password protected by the Apache Unomi (Karaf) password. You can 
find this user and password information
-in the etc/users.properties file.
+Both URLs are password protected by the Apache Unomi (Karaf) password: the 
`karaf` user and the
+`UNOMI_ROOT_PASSWORD` you configured at startup. No default password is 
shipped, and
+`etc/users.properties` only references the configured value rather than 
containing it.
 
 You can now use the connectors's defined actions in rules to push or pull data 
to/from the Salesforce CRM. You can
 find more information about rules in the <<_data_model_overview,Data Model>> 
and the <<_getting_started_with_unomi,Getting Started>> pages.
@@ -152,7 +153,7 @@ mvn clean install
 +
 [source]
 ----
-ssh -p 8102 karaf@localhost (password by default is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured 
UNOMI_ROOT_PASSWORD)
 ----
 +
 . Execute the following commands in the Karaf shell
@@ -171,7 +172,7 @@ feature:install unomi-salesforce-connector-karaf-feature
 https://localhost:9443/cxs/sfdc/version
 ----
 +
-(if asked for a password it's the same karaf/karaf default)
+(if asked for credentials, use the `karaf` user and your configured 
`UNOMI_ROOT_PASSWORD`)
 
 ==== Using the Salesforce Workbench for testing REST API
 
diff --git a/manual/src/main/asciidoc/getting-started.adoc 
b/manual/src/main/asciidoc/getting-started.adoc
index 223cb3848..1351cbc95 100644
--- a/manual/src/main/asciidoc/getting-started.adoc
+++ b/manual/src/main/asciidoc/getting-started.adoc
@@ -50,6 +50,21 @@ Note for OpenSearch users:
 
 ==== Running Unomi
 
+===== Set the admin and health passwords (required)
+
+Unomi does not ship known default passwords. Set both *before* starting the 
server
+(otherwise startup fails with a clear error):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
+Authenticate later with `karaf:$UNOMI_ROOT_PASSWORD`. See the configuration 
chapter if you prefer to set
+`org.apache.unomi.security.root.password` / 
`org.apache.unomi.healthcheck.password` in
+`etc/custom.system.properties` instead.
+
 ===== Start Unomi
 
 Start Unomi according to the <<_five_minutes_quickstart,quick start with 
docker>> or by compiling using the
@@ -67,14 +82,14 @@ Initializing profile service endpoint...
 Initializing cluster service endpoint...
 ----
 
-This indicates that all the Unomi services are started and ready to react to 
requests. 
+This indicates that all the Unomi services are started and ready to react to 
requests.
 
-Before you can use the API, you need to create a tenant:
+Create a tenant (using the password you set before startup):
 
 [source,bash]
 ----
 curl -X POST http://localhost:8181/cxs/tenants \
-  --user karaf:karaf \
+  --user "karaf:${UNOMI_ROOT_PASSWORD}" \
   -H "Content-Type: application/json" \
   -d '{
     "requestedId": "default",
@@ -89,8 +104,8 @@ The tenant create response includes **masked** API key 
metadata only. Regenerate
 
 [source,bash]
 ----
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE"; 
--user "karaf:${UNOMI_ROOT_PASSWORD}"
 ----
 
 Store `plainTextKey` immediately — you'll need the public key for subsequent 
API calls. See <<_multitenancy,Multi-tenancy>> for authentication details.
diff --git a/manual/src/main/asciidoc/graphql-examples.adoc 
b/manual/src/main/asciidoc/graphql-examples.adoc
index 847a7ac99..57656e60a 100644
--- a/manual/src/main/asciidoc/graphql-examples.adoc
+++ b/manual/src/main/asciidoc/graphql-examples.adoc
@@ -171,13 +171,16 @@ To make this query work you need to supply authorization 
token in the `HTTP head
 [source,json]
 ----
 {
-  "authorization": "Basic a2FyYWY6a2FyYWY="
+  "authorization": "Basic BASE64_OF_KARAF_AND_ROOT_PASSWORD"
 }
 ----
 
-NOTE: GraphQL requests need authentication. For **mutations and administrative 
queries**, use HTTP Basic with JAAS (`karaf:karaf`) or 
`tenantId:privateApiKey`. For **public read** operations on the `cdp` root 
field, send `X-Unomi-Api-Key` with a tenant public API key. See 
<<_graphql_api,GraphQL API>> authentication.
+Generate the Base64 value from your own credentials, for example with
+`printf 'karaf:%s' "$UNOMI_ROOT_PASSWORD" | base64`.
 
-When using curl, you can use the `--user` option instead of manually encoding 
credentials. For example, `--user karaf:karaf` or `--user 
TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic 
authentication. For public reads:
+NOTE: GraphQL requests need authentication. For **mutations and administrative 
queries**, use HTTP Basic with JAAS (the `karaf` user and your configured 
`UNOMI_ROOT_PASSWORD`) or `tenantId:privateApiKey`. For **public read** 
operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public 
API key. See <<_graphql_api,GraphQL API>> authentication.
+
+When using curl, you can use the `--user` option instead of manually encoding 
credentials. For example, `--user "karaf:$UNOMI_ROOT_PASSWORD"` or `--user 
TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic 
authentication. For public reads:
 
 [source,bash]
 ----
diff --git a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc 
b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
index 2d4a01440..273005155 100644
--- a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
+++ b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
@@ -16,7 +16,7 @@
 
 The JSON schema endpoints are private, so the user has to be authenticated to 
manage the JSON schema in Unomi.
 
-IMPORTANT: JSON schema endpoints require tenant authentication using Basic 
Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses 
system administrator authentication (`karaf:karaf`).
+IMPORTANT: JSON schema endpoints require tenant authentication using Basic 
Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses 
system administrator authentication (the `karaf` user and your configured 
`UNOMI_ROOT_PASSWORD`).
 
 ==== List existing schemas
 
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc 
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index 18b69e288..a041bf2d2 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -34,7 +34,7 @@ The main change in 3.1 is the introduction of tenant-based 
authentication. The s
 |Aspect |Unomi 3.0 |Unomi 3.1
 
 |Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (configured admin password)
 |Tenant-based API Keys + System Administrator Authentication
 
 |Public API Endpoints
@@ -46,8 +46,8 @@ The main change in 3.1 is the introduction of tenant-based 
authentication. The s
 |Tenant Authentication (tenantId/privateKey) OR System Administrator 
Authentication
 
 |Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication
+|System Administrator Authentication (configured `UNOMI_ROOT_PASSWORD`)
 |===
 
 ==== API Key Types (3.1 Only)
@@ -68,12 +68,12 @@ The main change in 3.1 is the introduction of tenant-based 
authentication. The s
 |Public API Key only
 
 |Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|Tenant Auth (tenantId/privateKey) OR System Admin 
(`karaf:$UNOMI_ROOT_PASSWORD`)
 
 |Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
 |===
 
 ==== Authentication Flow (3.1)
@@ -84,7 +84,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via 
`X-Unomi-Api-Key` header
 3. **Private endpoints**: Tries tenant authentication first, then falls back 
to system administrator authentication:
    - **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
-   - **System Administrator Authentication**: Basic Auth with `karaf:karaf` 
(or configured admin credentials)
+   - **System Administrator Authentication**: Basic Auth with 
`karaf:$UNOMI_ROOT_PASSWORD` (or configured admin credentials)
 
 ==== Code Examples
 
@@ -94,7 +94,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
 ----
 // Global system administrator authentication for all endpoints
 RestAssured.authentication = RestAssured.preemptive()
-    .basic("karaf", "karaf");
+    .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 
 // Context requests require no authentication
 RestAssured.given()
@@ -124,14 +124,14 @@ given()
 
 // For private endpoints using system administrator authentication
 given()
-    .auth().preemptive().basic("karaf", "karaf")
+    .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
     .contentType(ContentType.JSON)
     .body(payload)
     .post("/cxs/profiles");
 
 // For tenant administration (system admin only)
 given()
-    .auth().preemptive().basic("karaf", "karaf")
+    .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
     .contentType(ContentType.JSON)
     .body(tenantPayload)
     .post("/cxs/tenants");
@@ -164,7 +164,7 @@ public class UnomiConfiguration {
 public void init() {
     RestAssured.baseURI = baseUrl;
     RestAssured.authentication = RestAssured.preemptive()
-        .basic("karaf", "karaf");
+        .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 }
 
 // 3.1 Client
@@ -210,10 +210,87 @@ When migrating to 3.1, you need to understand that:
    - Keep system administrator authentication as fallback for administrative 
operations
    - Continue using system administrator authentication for tenant 
administration
 
-4. **No API Contract Changes**
-   - All endpoints remain the same
-   - Request/response payloads are unchanged
-   - Only authentication mechanism differs
+4. **Related client contract changes (3.1 hardening)**
+   - Public `/context.json` and `/eventcollector` no longer treat body/query 
`profileId` as identity — see below
+   - Endpoint paths and most payload shapes remain the same; authentication 
and public profile-binding rules change
+
+[#_client_facing_hardening_3_1]
+==== Client-facing hardening (profile cookie, passwords, privileged APIs)
+
+In addition to tenant API keys, Unomi 3.1 hardens several contracts that can 
break older clients and scripts. See also <<_how_profile_tracking_works,How 
profile tracking works>> and 
https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972].
+
+**Why cookie-only for public profile identity?** On public context endpoints 
the profile id is a bearer token. Accepting a client-chosen body/query 
`profileId` let any public caller try to continue another visitor's context if 
they obtained that UUID. The cookie is issued by Unomi, sent automatically by 
the browser, and (with default `HttpOnly`) is not readable from page script — 
so public clients prove continuity with a server-issued bearer instead of an 
application-supplied id. Trusted [...]
+
+[cols="1,2,2", options="header"]
+|===
+|Area |Old common pattern |Required in 3.1
+
+|Public profile identity
+|Body/query `profileId` often selected the profile (sometimes ahead of the 
cookie)
+|Public callers: **cookie only**. Body/query `profileId` is ignored. Trusted 
callers (tenant private key / system admin) may still override.
+
+|Session resume without cookie
+|Sending only `sessionId` could switch onto that session's profile
+|Public callers switch only if the cookie already owns the session; otherwise 
the session is detached for the request.
+
+|Profile cookie `HttpOnly`
+|Often defaulted to `false` (JS could read `document.cookie`)
+|Default **`true`**. Prefer `profileId` from the JSON response. Opt out with 
`org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the 
tradeoff.
+
+|Admin / health passwords
+|Known defaults such as `karaf` / `karaf` were common in samples
+|Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no 
shipped known defaults).
+
+|Groovy Actions / Router import-export REST
+|Often callable with tenant private key
+|Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator.
+
+|Startup without a password
+|Started with the shipped default
+|`bin/karaf` and the Docker entrypoint refuse to start. On Windows startup 
continues — see the warning below.
+
+|`mergeProfilesOnProperty` / cross-profile `updateProperties` / 
`systemProperties.*`
+|Sometimes driven from public context events
+|Cross-profile merge/update and `systemProperties` writes require a trusted 
caller (system or tenant admin). Public callers may still update the 
**current** cookie-bound profile's normal properties when the event type allows 
it.
+|===
+
+[WARNING]
+====
+**Windows: the startup check cannot stop the launcher.**
+
+On Linux and macOS, `bin/karaf` sources `bin/setenv` directly, so a missing 
password aborts startup.
+The Docker entrypoint behaves the same way and the container exits.
+
+On Windows, `karaf.bat` runs `setenv.bat` with `call` and does not test the 
exit code, so the error
+message is printed and **startup continues anyway**. This is a limitation of 
the Karaf launcher that
+Unomi cannot work around without replacing `karaf.bat`.
+
+This matters because an unset password is not the same as a disabled account. 
`${env:UNOMI_ROOT_PASSWORD}`
+resolves to the *empty string*, so `etc/users.properties` creates the `karaf` 
administrator with an
+empty password that authenticates successfully.
+
+Windows operators must therefore treat the message as fatal and verify the 
password took effect
+before exposing the instance — for example by confirming that an empty 
password is rejected:
+
+[source,bash]
+----
+# Must return 401. A 200 means the account has a blank password.
+curl -i -u "karaf:" http://localhost:8181/cxs/tenants
+----
+
+Unomi additionally refuses any REST call presenting Basic authentication with 
an empty password, so
+the admin API is not reachable that way even if the server did start 
unconfigured. The Karaf SSH
+console (port 8102) is not covered by that check.
+====
+
+===== Migration checklist for client applications
+
+* Browser / tracker clients: ensure cookies are sent (`withCredentials` / 
same-site setup); stop treating body `profileId` as authoritative for public 
calls.
+* Headless or mobile public clients that stored a UUID and posted it only in 
the body: switch to sending the profile cookie, or call with a **private key** 
when you intentionally bind a profile.
+* Login / merge flows: emit merge-triggering events from a trusted server-side 
caller after real authentication — not from the public key alone.
+* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` 
with `karaf:$UNOMI_ROOT_PASSWORD`.
+* Windows deployments: confirm the passwords actually took effect after 
upgrading — the startup check warns but cannot halt `karaf.bat` (see the 
warning above).
+* Automation that uploaded Groovy actions or managed Router import/export with 
a tenant private key: switch to system administrator credentials.
 
 ==== Benefits of Multi-Tenancy in 3.1
 
@@ -232,6 +309,7 @@ Before starting the migration, please ensure that:
 - You are currently running Apache Unomi 3.0 (or a later 3.0.x version)
 - You understand the multi-tenancy impact on your data model
 - You have a plan to update client applications to tenant API keys (or 
temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 
2.x)
+- You have reviewed the <<_client_facing_hardening_3_1,client-facing 
hardening>> notes (cookie-only public profile binding, HttpOnly default, 
required passwords, privileged REST roles)
 - You know how to obtain plaintext API keys after upgrade (regenerate via 
`/cxs/tenants/{id}/apikeys`; create responses expose masked keys only)
 
 === Migration Process
@@ -293,7 +371,7 @@ The fundamental difference between Unomi 3.0 and 3.1 is the 
introduction of **co
 
 - **3.0**: Single-tenant architecture with system administrator authentication 
for all operations
 - **3.1**: Multi-tenant architecture with complete data isolation and 
tenant-specific authentication
-- **API Endpoints**: Identical between versions - no breaking changes to 
existing integrations
+- **API Endpoints**: Paths remain the same; authentication and public 
profile-binding rules change (see client-facing hardening above)
 - **Data Model**: All entities (profiles, events, segments, rules, schemas) 
become tenant-specific in 3.1
 - **Authentication**: New tenant-based authentication model with system 
administrator authentication as fallback
 
diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc 
b/manual/src/main/asciidoc/migrations/migrations.adoc
index 9cbe00146..6faa3da16 100644
--- a/manual/src/main/asciidoc/migrations/migrations.adoc
+++ b/manual/src/main/asciidoc/migrations/migrations.adoc
@@ -17,7 +17,7 @@ This section contains information and steps to migrate 
between major Unomi versi
 Use this decision guide to pick the right runbook:
 
 * **Unomi 2.x → 3.0** (platform): <<_migrate_from_2_x_to_3_0,Migrate from 2.x 
to 3.0>> (+ <<_migrate_from_elasticsearch_7_to_elasticsearch_9,ES7→ES9>> if 
needed)
-* **Unomi 3.0 → 3.1** (tenants / API keys): <<_migrate_from_3_0_to_3_1,Migrate 
from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 
compatibility mode>> for 2.x clients
+* **Unomi 3.0 → 3.1** (tenants / API keys / client hardening): 
<<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); 
optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients; see 
also <<_client_facing_hardening_3_1,client-facing hardening>>
 * **Elasticsearch → OpenSearch** (same Unomi version, backend swap): 
<<_migrate_from_elasticsearch_to_opensearch,Migrate from Elasticsearch to 
OpenSearch>> (not `unomi:migrate`)
 
 [plantuml]
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc 
b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index 0614d523b..623820939 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -111,7 +111,7 @@ Your V2 clients should now work without any changes:
 ```java
 // V2-style authentication still works
 RestAssured.authentication = RestAssured.preemptive()
-    .basic("karaf", "karaf");
+    .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 
 // Context requests work without API keys
 RestAssured.given()
@@ -137,7 +137,7 @@ Over time, gradually update your clients to use V3 
authentication:
 ```java
 // This continues to work in V2 compatibility mode
 RestAssured.authentication = RestAssured.preemptive()
-    .basic("karaf", "karaf");
+    .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 
 RestAssured.given()
     .auth().none()
@@ -260,7 +260,7 @@ The V2 third-party configuration supports dynamic updates:
 - Ensure the tenant exists and is accessible
 
 **Authentication errors**:
-- Verify system administrator credentials (karaf/karaf)
+- Verify system administrator credentials (the `karaf` user and your 
configured `UNOMI_ROOT_PASSWORD`)
 - Check that the server is running properly
 - Review logs for authentication errors
 
diff --git a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc 
b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
index 4eb64ca28..fbf69a019 100644
--- a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
@@ -40,7 +40,7 @@ This multi-tenancy support necessitates the authentication 
changes described bel
 |Aspect |Unomi V2 |Unomi 3.1
 
 |Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
 |Tenant-based API Keys + System Administrator Authentication
 
 |Public API Endpoints
@@ -52,8 +52,8 @@ This multi-tenancy support necessitates the authentication 
changes described bel
 |Tenant Authentication (tenantId/privateKey) OR System Administrator 
Authentication
 
 |Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
+|System Administrator Authentication (`karaf` user)
 |===
 
 ===== API Key Types (V3 Only)
@@ -74,12 +74,12 @@ V3 introduces two types of API keys per tenant:
 |Public API Key only
 
 |Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf` user)
 
 |Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|System Admin (`karaf` user)
 |===
 
 ==== Authentication Flow (V3)
@@ -90,7 +90,7 @@ The AuthenticationFilter in V3 follows this resolution order:
 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via 
`X-Unomi-Api-Key` header
 3. **Private endpoints**: Tries tenant authentication first, then falls back 
to system administrator authentication:
    - **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
-   - **System Administrator Authentication**: Basic Auth with `karaf:karaf` 
(or configured admin credentials)
+   - **System Administrator Authentication**: Basic Auth with the `karaf` user 
and your configured `UNOMI_ROOT_PASSWORD`
 
 ==== Code Examples
 
@@ -100,7 +100,7 @@ The AuthenticationFilter in V3 follows this resolution 
order:
 ----
 // Global system administrator authentication for all endpoints
 RestAssured.authentication = RestAssured.preemptive()
-    .basic("karaf", "karaf");
+    .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 
 // Context requests require no authentication
 RestAssured.given()
@@ -130,14 +130,14 @@ given()
 
 // For private endpoints using system administrator authentication
 given()
-    .auth().preemptive().basic("karaf", "karaf")
+    .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
     .contentType(ContentType.JSON)
     .body(payload)
     .post("/cxs/profiles");
 
 // For tenant administration (system admin only)
 given()
-    .auth().preemptive().basic("karaf", "karaf")
+    .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
     .contentType(ContentType.JSON)
     .body(tenantPayload)
     .post("/cxs/tenants");
@@ -170,7 +170,7 @@ public class UnomiConfiguration {
 public void init() {
     RestAssured.baseURI = baseUrl;
     RestAssured.authentication = RestAssured.preemptive()
-        .basic("karaf", "karaf");
+        .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
 }
 
 // V3 Client
diff --git a/manual/src/main/asciidoc/scheduler.adoc 
b/manual/src/main/asciidoc/scheduler.adoc
index 786fdbc87..3783a767e 100644
--- a/manual/src/main/asciidoc/scheduler.adoc
+++ b/manual/src/main/asciidoc/scheduler.adoc
@@ -645,14 +645,14 @@ NOTE: The REST API is for **monitoring and operations** 
(list, inspect, cancel,
 .List all tasks (paginated)
 [source,bash]
 ----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
   "http://localhost:8181/cxs/tasks?offset=0&limit=20";
 ----
 
 .Filter by status
 [source,bash]
 ----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
   "http://localhost:8181/cxs/tasks?status=FAILED&limit=50";
 ----
 
@@ -661,7 +661,7 @@ Valid status values: `SCHEDULED`, `WAITING`, `RUNNING`, 
`COMPLETED`, `FAILED`, `
 .Filter by task type
 [source,bash]
 ----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
   "http://localhost:8181/cxs/tasks?type=cache-refresh-segment&limit=10";
 ----
 
@@ -689,7 +689,7 @@ curl -s -u karaf:karaf \
 
 [source,bash]
 ----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
   "http://localhost:8181/cxs/tasks/TASK_ID";
 ----
 
@@ -699,7 +699,7 @@ Cancellation stops future runs and marks the task 
`CANCELLED`. The task record r
 
 [source,bash]
 ----
-curl -s -u karaf:karaf -X DELETE \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X DELETE \
   "http://localhost:8181/cxs/tasks/TASK_ID";
 ----
 
@@ -710,11 +710,11 @@ Returns HTTP `204 No Content` on success.
 [source,bash]
 ----
 # Retry keeping failure count
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
   "http://localhost:8181/cxs/tasks/TASK_ID/retry";
 
 # Retry and reset failure count to zero
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
   "http://localhost:8181/cxs/tasks/TASK_ID/retry?resetFailureCount=true";
 ----
 
@@ -724,7 +724,7 @@ Use when status is `CRASHED` and the executor supports 
checkpoint resume.
 
 [source,bash]
 ----
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
   "http://localhost:8181/cxs/tasks/TASK_ID/resume";
 ----
 
diff --git a/manual/src/main/asciidoc/security.adoc 
b/manual/src/main/asciidoc/security.adoc
index acfdd1b1f..fb14fbec1 100644
--- a/manual/src/main/asciidoc/security.adoc
+++ b/manual/src/main/asciidoc/security.adoc
@@ -31,12 +31,14 @@ Unomi 3.1 authenticates every request so the server can 
resolve a **tenant** (or
 | Server-side integrations, admin UIs
 
 | Tenant administration (`/cxs/tenants`)
-| JAAS system administrator (for example `karaf:karaf`)
+| JAAS system administrator (for example `karaf:$UNOMI_ROOT_PASSWORD`)
 | Create tenants, rotate keys
 |===
 
 Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows 
public endpoints without API keys while migrating 2.x clients.
 
+Public context callers must present the profile cookie as the profile bearer 
(body `profileId` is ignored for public callers). See 
<<_client_facing_hardening_3_1,client-facing hardening>> and 
<<_how_profile_tracking_works,How profile tracking works>>.
+
 ===== Auth resolution sequence
 
 [plantuml]
diff --git a/manual/src/main/asciidoc/shell-commands.adoc 
b/manual/src/main/asciidoc/shell-commands.adoc
index 19a09dca6..2a154d5d0 100644
--- a/manual/src/main/asciidoc/shell-commands.adoc
+++ b/manual/src/main/asciidoc/shell-commands.adoc
@@ -26,7 +26,7 @@ You can connect to the Apache Karaf SSH Shell using the 
following command:
 
     ssh -p 8102 karaf@localhost
 
-The default username/password is karaf/karaf. You should change this as soon 
as possible by editing the `etc/users.properties` file.
+Authenticate with the `karaf` user and the password from `UNOMI_ROOT_PASSWORD` 
(Unomi does not ship a known default password). Set the password before start; 
see <<_getting_started,Getting started>> and 
<<_client_facing_hardening_3_1,client-facing hardening>>.
 
 Once connected you can simply type in :
 
diff --git a/manual/src/main/asciidoc/tutorial.adoc 
b/manual/src/main/asciidoc/tutorial.adoc
index f381ce6d1..0d81767c3 100644
--- a/manual/src/main/asciidoc/tutorial.adoc
+++ b/manual/src/main/asciidoc/tutorial.adoc
@@ -115,7 +115,7 @@ curl --location --request POST 
'http://localhost:8181/cxs/scopes' \
   }'
 ----
 
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and 
private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator 
authentication (`karaf:karaf`). The default `karaf:karaf` credentials should be 
changed as soon as possible by modifying the `etc/users.properties` file.
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and 
private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator 
authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). 
Unomi ships no default administrator password: set `UNOMI_ROOT_PASSWORD` (and 
`UNOMI_HEALTHCHECK_PASSWORD`) before starting the server, or it will refuse to 
start.
 
 ==== Using tracker in your own JavaScript projects
 
@@ -210,7 +210,7 @@ Another (powerful) way to look at events is to use the SSH 
Console. You can conn
     ssh -p 8102 karaf@localhost
 ----
 
-Using the same username password (karaf:karaf) and then you can use command 
such as :
+Using the same credentials (the `karaf` user and your configured 
`UNOMI_ROOT_PASSWORD`) and then you can use command such as :
 
 - `event-tail` to view in realtime the events as they come in (CTRL+C to stop)
 - `event-list` to view the latest events
diff --git a/manual/src/main/asciidoc/whats-new.adoc 
b/manual/src/main/asciidoc/whats-new.adoc
index 4b34f3f90..d49febed1 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -21,9 +21,17 @@ Apache Unomi 3.1 builds on the 3.0 platform (Elasticsearch 9 
client, Karaf 4.4,
 Complete tenant isolation for profiles, events, segments, rules, and schemas. 
Public endpoints (for example `/cxs/context.json`) require a tenant public API 
key; administrative work uses tenant private keys or system administrator 
credentials.
 
 * Operator guide: <<_multitenancy,Multi-tenancy>>
-* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>>
+* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes 
<<_client_facing_hardening_3_1,client-facing hardening>>)
 * Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> 
(`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`)
 
+==== Security hardening (credentials, profile binding, privileged APIs)
+
+Unomi 3.1 requires explicit admin and health-check passwords at startup, 
treats the profile cookie as the only public profile bearer on `/context.json` 
and `/eventcollector`, defaults the profile cookie to `HttpOnly`, restricts 
Groovy Actions and Router import/export REST to system administrator, and gates 
cross-profile merge / `updateProperties` / `systemProperties` writes to trusted 
callers.
+
+* Details and migration table: <<_client_facing_hardening_3_1,Client-facing 
hardening>>
+* Profile tracking contract: <<_how_profile_tracking_works,How profile 
tracking works>>
+* Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]
+
 ==== Cluster-aware task scheduler
 
 Built-in background job scheduler with persistence, cluster locks, recovery, 
REST API (`/cxs/tasks`), and Karaf shell commands.
diff --git a/package/src/main/resources/bin/setenv 
b/package/src/main/resources/bin/setenv
index e48561de4..66fa7157d 100755
--- a/package/src/main/resources/bin/setenv
+++ b/package/src/main/resources/bin/setenv
@@ -50,6 +50,94 @@
 MY_DIRNAME=`dirname $0`
 MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd`
 
+# Fail fast when starting without admin/health passwords (no known defaults 
are shipped).
+#
+# An unset password does NOT resolve to "no account": ${env:...} with no value 
expands to the
+# empty string, which Karaf's PropertiesLoginModule accepts as a valid (empty) 
password for the
+# admin account. So "unset" and "blank" are the same failure, and both must be 
refused.
+#
+# This script is the gate for shell-launched Karaf; the Docker entrypoint 
covers containers. Any
+# launcher that starts the JVM directly without sourcing this file (systemd 
units, Kubernetes
+# command overrides, PaxExam) bypasses the check, so those deployments must 
set the passwords
+# themselves.
+_unomi_etc="${KARAF_ETC:-${MY_KARAF_HOME}/etc}"
+
+# Print the configured value of a property, searching custom.system.properties 
and the optional
+# overlay it includes. Last assignment wins, comments ignored. Empty output 
means "not configured".
+_unomi_configured_property() {
+  for _unomi_file in "${_unomi_etc}/custom.system.properties" 
"${_unomi_etc}/unomi.custom.system.properties"; do
+    [ -f "${_unomi_file}" ] || continue
+    sed -n "s|^[[:space:]]*$1[[:space:]]*=[[:space:]]*\(.*\)$|\1|p" 
"${_unomi_file}"
+  done | sed -e 's/[[:space:]]*$//' | grep -v '^$' | tail -n 1
+}
+
+# _unomi_require_password ENV_VAR_NAME PROPERTY_NAME SKIP_FLAG_NAME
+# Returns 0 when a non-blank password is available, 1 when startup must be 
refused.
+_unomi_require_password() {
+  eval _unomi_value=\"\${$1}\"
+  eval _unomi_skip=\"\${$3}\"
+
+  [ -n "${_unomi_value}" ] && return 0
+
+  _unomi_configured=`_unomi_configured_property "$2"`
+  case "${_unomi_configured}" in
+    # An unresolved ${env:...} placeholder is not a configured password — it 
expands to empty.
+    *'${'*) _unomi_configured='' ;;
+  esac
+  [ -n "${_unomi_configured}" ] && return 0
+
+  if [ "${_unomi_skip}" = "true" ]; then
+    cat >&2 <<EOF
+
+WARNING: $3=true, but neither $1 nor
+         $2 is set to a non-empty value.
+         If nothing else supplies this password, the account will be created 
with an EMPTY
+         password that authenticates successfully, granting full administrator 
access.
+
+EOF
+    return 0
+  fi
+
+  cat >&2 <<EOF
+ERROR: $1 is not set.
+
+Apache Unomi does not ship a known default password, and an unset value 
becomes an EMPTY
+password that still authenticates. Set one before starting, for example:
+
+  export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+  export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+  ./bin/karaf
+
+Or set $2 in etc/custom.system.properties
+(then $3=true only suppresses this early check).
+
+See getting started / configuration documentation for details.
+EOF
+  return 1
+}
+
+# Only guard the scripts that actually start the server. bin/stop, bin/status, 
bin/client and
+# bin/shell set KARAF_SCRIPT to their own name, so the outer case already 
excludes them.
+#
+# "bin/karaf stop" and "bin/karaf status" reuse KARAF_SCRIPT=karaf, so skip on 
the subcommand too:
+# those are the only two that replace the main class (with Main.Stop / 
Main.Status) instead of
+# booting the server. Everything else - including "karaf client", "karaf 
shell" and any unknown
+# argument - falls through to org.apache.karaf.main.Main and starts a full 
server, so it must be
+# guarded.
+case "${KARAF_SCRIPT}" in
+  karaf|start|server)
+    case "$1" in
+      stop|status) ;;
+      *)
+        _unomi_require_password UNOMI_ROOT_PASSWORD \
+            org.apache.unomi.security.root.password 
UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1
+        _unomi_require_password UNOMI_HEALTHCHECK_PASSWORD \
+            org.apache.unomi.healthcheck.password 
UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1
+        ;;
+    esac
+    ;;
+esac
+
 # In order to activate the Yourkit Profiler agent, uncomment one of the 
following lines depending on the operating
 # system and adjust the path to the location of the installation directory.
 # On MacOS:
diff --git a/package/src/main/resources/bin/setenv.bat 
b/package/src/main/resources/bin/setenv.bat
index f5db6f80b..5333a1090 100644
--- a/package/src/main/resources/bin/setenv.bat
+++ b/package/src/main/resources/bin/setenv.bat
@@ -63,3 +63,64 @@ rem SET KARAF_DEBUG
 
 set MY_DIRNAME=%~dp0%
 set MY_KARAF_HOME=%DIRNAME%..
+
+rem Warn early when starting without admin/health passwords (no known defaults 
are shipped).
+rem
+rem An unset password does NOT mean "no account": it expands to the empty 
string, which Karaf's
+rem PropertiesLoginModule accepts as a valid password for the admin account.
+rem
+rem KNOWN LIMITATION: karaf.bat invokes this file with "call" and does not 
test errorlevel
+rem afterwards, so the "exit /b 1" below returns from this script but does NOT 
stop the launcher.
+rem The error message is printed and startup continues. Windows operators must 
therefore act on the
+rem message; there is no way to fail closed from here without also killing the 
operator's console
+rem (plain "exit 1" would terminate the whole cmd.exe session, including an 
interactive prompt).
+rem
+rem karaf.bat sets KARAF_SCRIPT with the quotes included in the value (SET 
KARAF_SCRIPT="karaf.bat"),
+rem so strip them before comparing - otherwise every comparison below silently 
fails to match.
+set _UNOMI_KARAF_SCRIPT=%KARAF_SCRIPT:"=%
+if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf.bat" goto checkPasswords
+if /I "%_UNOMI_KARAF_SCRIPT%"=="start.bat" goto checkPasswords
+if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf" goto checkPasswords
+if /I "%_UNOMI_KARAF_SCRIPT%"=="start" goto checkPasswords
+goto afterPasswordChecks
+
+:checkPasswords
+if not "%UNOMI_ROOT_PASSWORD%"=="" goto afterRootPasswordCheck
+if /I "%UNOMI_SKIP_ROOT_PASSWORD_CHECK%"=="true" goto afterRootPasswordCheck
+echo ERROR: UNOMI_ROOT_PASSWORD is not set.
+echo.
+echo Apache Unomi does not ship a known default admin password, and an unset 
value becomes
+echo an EMPTY password that still authenticates. Set one before starting, for 
example:
+echo.
+echo   set UNOMI_ROOT_PASSWORD=choose-a-strong-password
+echo   set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
+echo   bin\karaf.bat
+echo.
+echo Or set org.apache.unomi.security.root.password in 
etc\custom.system.properties
+echo and set UNOMI_SKIP_ROOT_PASSWORD_CHECK=true.
+echo.
+echo WARNING: startup continues anyway on Windows - see the note at the top of 
this file.
+set _UNOMI_KARAF_SCRIPT=
+exit /b 1
+
+:afterRootPasswordCheck
+if not "%UNOMI_HEALTHCHECK_PASSWORD%"=="" goto afterPasswordChecks
+if /I "%UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK%"=="true" goto 
afterPasswordChecks
+echo ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set.
+echo.
+echo Apache Unomi does not ship a known default health-check password, and an 
unset value
+echo becomes an EMPTY password that still authenticates. Set one before 
starting, for example:
+echo.
+echo   set UNOMI_ROOT_PASSWORD=choose-a-strong-password
+echo   set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
+echo   bin\karaf.bat
+echo.
+echo Or set org.apache.unomi.healthcheck.password in 
etc\custom.system.properties
+echo and set UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true.
+echo.
+echo WARNING: startup continues anyway on Windows - see the note at the top of 
this file.
+set _UNOMI_KARAF_SCRIPT=
+exit /b 1
+
+:afterPasswordChecks
+set _UNOMI_KARAF_SCRIPT=
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index fa98f7979..658d9caba 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -22,7 +22,13 @@ ${optionals}=unomi.custom.system.properties
 
#######################################################################################################################
 ## Security settings                                                           
                                      ##
 
#######################################################################################################################
-org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD:-karaf}
+# Required: set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start
+# (bin/setenv and the Docker entrypoint refuse to start if either is unset).
+# There are no shipped known defaults.
+# Escape hatches: UNOMI_SKIP_ROOT_PASSWORD_CHECK=true / 
UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true
+# if you set the matching property another way.
+org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD}
+org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD}
 
 # These parameters control the list of classes that are allowed or forbidden 
when executing expressions.
 
org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String}
diff --git a/package/src/main/resources/etc/users.properties 
b/package/src/main/resources/etc/users.properties
index bffdc5bf3..c7064f536 100644
--- a/package/src/main/resources/etc/users.properties
+++ b/package/src/main/resources/etc/users.properties
@@ -29,6 +29,8 @@
 # and modifiable via the JAAS command group. These users reside in a JAAS 
domain
 # with the name "karaf".
 #
-karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup
-health = ${org.apache.unomi.healthcheck.password:-health},health
+# Passwords come from org.apache.unomi.security.root.password / 
healthcheck.password (see custom.system.properties).
+# No known defaults are shipped; set UNOMI_ROOT_PASSWORD and 
UNOMI_HEALTHCHECK_PASSWORD before start.
+karaf = ${org.apache.unomi.security.root.password},_g_:admingroup
+health = ${org.apache.unomi.healthcheck.password},health
 _g_\:admingroup = 
group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_ADMIN,ROLE_UNOMI_TENANT_USER
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
 
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index 3c59c1cf6..0e990ffaa 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -101,17 +101,33 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
                               TenantService tenantService,
                               SecurityService securityService,
                               ExecutionContextManager executionContextManager) 
{
+        this(restAuthenticationConfig, tenantService, securityService, 
executionContextManager, buildJaasFilter());
+    }
+
+    /**
+     * Test seam: lets a test supply a stub JAAS filter so it can assert that 
a credential was
+     * refused <em>before</em> it reached JAAS. Asserting on the response 
status alone proves
+     * nothing here — every refusal path in this class ends in the same 401.
+     */
+    AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig,
+                              TenantService tenantService,
+                              SecurityService securityService,
+                              ExecutionContextManager executionContextManager,
+                              JAASAuthenticationFilter 
jaasAuthenticationFilter) {
         this.restAuthenticationConfig = restAuthenticationConfig;
         this.tenantService = tenantService;
         this.securityService = securityService;
         this.executionContextManager = executionContextManager;
+        this.jaasAuthenticationFilter = jaasAuthenticationFilter;
+    }
 
-        // Build wrapped jaas filter
-        jaasAuthenticationFilter = new JAASAuthenticationFilter();
-        jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER);
-        jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
-        jaasAuthenticationFilter.setContextName(CONTEXT_NAME);
-        jaasAuthenticationFilter.setRealmName(REALM_NAME);
+    private static JAASAuthenticationFilter buildJaasFilter() {
+        JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter();
+        jaasFilter.setRoleClassifier(ROLE_CLASSIFIER);
+        jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
+        jaasFilter.setContextName(CONTEXT_NAME);
+        jaasFilter.setRealmName(REALM_NAME);
+        return jaasFilter;
     }
 
     @Override
@@ -133,6 +149,9 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
                     unauthorized(requestContext);
                     return;
                 }
+                if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+                    return;
+                }
 
                 try {
                     jaasAuthenticationFilter.filter(requestContext);
@@ -192,6 +211,9 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
             // For all other cases, try tenant private key first, then fall 
back to JAAS
             String authHeader = 
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
             if (authHeader != null && 
authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+                if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+                    return;
+                }
                 // Try tenant private key authentication first
                 String[] credentials = extractBasicAuthCredentials(authHeader);
                 if (credentials != null && credentials.length == 2) {
@@ -299,6 +321,9 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
         // For private endpoints, require system administrator authentication 
(like V2)
         String authHeader = 
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
         if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+            if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+                return;
+            }
             try {
                 jaasAuthenticationFilter.filter(requestContext);
                 // JAASAuthenticationFilter handles credential failures 
internally (calls abortWith + returns normally).
@@ -352,6 +377,45 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
         unauthorized(requestContext);
     }
 
+    /**
+     * Rejects the request when the Basic credential about to be used carries 
an empty password.
+     * <p>
+     * Defence in depth against a blank administrator password (UNOMI-972). If
+     * {@code org.apache.unomi.security.root.password} is unset, Karaf 
resolves it to the empty
+     * string and the shipped JAAS account authenticates with an empty 
password. {@code bin/karaf}
+     * and the Docker entrypoint refuse to start in that state, but they 
cannot cover every launcher
+     * (notably {@code karaf.bat}), so an empty credential is never accepted 
over REST either.
+     * <p>
+     * Deliberately called at each point where a Basic credential is actually 
consumed rather than
+     * once at the top of {@link #filter}: anonymous traffic on public paths — 
and every path in V2
+     * compatibility mode — ignores {@code Authorization} entirely, so 
rejecting up front would turn
+     * a stray or stale Basic header (a cached browser credential, an 
injecting proxy) into a 401 on
+     * a request that is supposed to succeed without authentication.
+     *
+     * @return {@code true} when the request has been aborted and the caller 
must return
+     */
+    private boolean rejectBlankBasicAuthPassword(ContainerRequestContext 
requestContext, String authHeader) {
+        if (!hasBlankBasicAuthPassword(authHeader)) {
+            return false;
+        }
+        logger.warn("Rejecting Basic authentication with an empty password");
+        unauthorized(requestContext);
+        return true;
+    }
+
+    /**
+     * Whether a Basic {@code Authorization} header carries an empty password. 
A missing, malformed
+     * or non-Basic header is not treated as blank here — those are rejected 
by the normal
+     * authentication paths instead.
+     */
+    boolean hasBlankBasicAuthPassword(String authHeader) {
+        if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+            return false;
+        }
+        String[] credentials = extractBasicAuthCredentials(authHeader);
+        return credentials != null && credentials.length == 2 && 
credentials[1].isEmpty();
+    }
+
     private String[] extractBasicAuthCredentials(String authHeader) {
         try {
             String base64Credentials = 
authHeader.substring(BASIC_AUTH_PREFIX.length()).trim();
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
new file mode 100644
index 000000000..19d1203fa
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.authentication;
+
+import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.TenantService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A blank {@code UNOMI_ROOT_PASSWORD} leaves the shipped JAAS administrator 
with an empty password
+ * that {@code PropertiesLoginModule} accepts. {@code bin/karaf} and the 
Docker entrypoint refuse to
+ * start in that state, but they cannot cover every launcher, so the REST 
layer must never accept an
+ * empty credential either.
+ * <p>
+ * Covers both the predicate and its wiring into {@link 
AuthenticationFilter#filter}: the check is
+ * applied where a Basic credential is consumed, and must NOT reject anonymous 
traffic on public
+ * paths that ignores {@code Authorization} altogether.
+ */
+class AuthenticationFilterBlankPasswordTest {
+
+    private RestAuthenticationConfig restAuthenticationConfig;
+    private TenantService tenantService;
+    private JAASAuthenticationFilter jaasAuthenticationFilter;
+    private AuthenticationFilter filter;
+
+    @BeforeEach
+    void setUp() {
+        restAuthenticationConfig = mock(RestAuthenticationConfig.class);
+        tenantService = mock(TenantService.class);
+        // Stubbed so the tests below can assert the credential never reached 
JAAS. Every refusal
+        // path in the filter answers 401, so the status alone cannot tell 
"refused for a blank
+        // password" apart from "JAAS rejected it" — only this can.
+        jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class);
+        filter = new AuthenticationFilter(
+                restAuthenticationConfig,
+                tenantService,
+                mock(SecurityService.class),
+                mock(ExecutionContextManager.class),
+                jaasAuthenticationFilter);
+    }
+
+    @Test
+    void emptyPasswordIsRejected() {
+        assertTrue(filter.hasBlankBasicAuthPassword(basic("karaf:")));
+    }
+
+    @Test
+    void emptyUserAndPasswordIsRejected() {
+        assertTrue(filter.hasBlankBasicAuthPassword(basic(":")));
+    }
+
+    @Test
+    void realPasswordIsAccepted() {
+        
assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf:a-strong-password")));
+    }
+
+    /**
+     * A password consisting of spaces is a real (if terrible) password, not 
the blank-resolution
+     * failure this guard exists for — leave it to the realm.
+     */
+    @Test
+    void whitespacePasswordIsNotTreatedAsBlank() {
+        assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf: ")));
+    }
+
+    @Test
+    void missingOrNonBasicHeadersAreLeftToTheNormalPaths() {
+        assertFalse(filter.hasBlankBasicAuthPassword(null));
+        assertFalse(filter.hasBlankBasicAuthPassword("Bearer some-token"));
+    }
+
+    @Test
+    void malformedHeaderIsLeftToTheNormalPaths() {
+        assertFalse(filter.hasBlankBasicAuthPassword("Basic not-base64!!"));
+        // No colon at all: cannot be split into user and password.
+        assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf")));
+    }
+
+    // ------------------------------------------------------------------ 
filter() wiring
+
+    /**
+     * The predicate being correct is not enough: if the call site were 
dropped or moved after an
+     * earlier {@code return}, only this test would notice.
+     */
+    @Test
+    void filterRejectsBlankPasswordOnAnAuthenticatedPath() throws IOException {
+        ContainerRequestContext requestContext = request("tenants", 
basic("karaf:"));
+
+        filter.filter(requestContext);
+
+        assertUnauthorizedWithoutReachingJaas(requestContext);
+    }
+
+    /** Control: a non-blank credential on the same path must still be handed 
to JAAS to judge. */
+    @Test
+    void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws 
IOException {
+        ContainerRequestContext requestContext = request("tenants", 
basic("karaf:a-strong-password"));
+
+        filter.filter(requestContext);
+
+        verify(jaasAuthenticationFilter).filter(requestContext);
+    }
+
+    /**
+     * V2 compatibility mode routes every request through {@link 
AuthenticationFilter}'s own
+     * private-endpoint branch, which consumes the Basic credential at a 
third, separate call site.
+     * Without this test that call site is unreachable from the suite: the 
other tests leave
+     * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code 
false}, so deleting
+     * the guard there would leave every test green.
+     */
+    @Test
+    void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() 
throws IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
basic("karaf:"));
+
+        filter.filter(requestContext);
+
+        assertUnauthorizedWithoutReachingJaas(requestContext);
+    }
+
+    /** Control for the V2 branch: a non-blank credential must still reach 
JAAS there too. */
+    @Test
+    void 
filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws 
IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
basic("karaf:a-strong-password"));
+
+        filter.filter(requestContext);
+
+        verify(jaasAuthenticationFilter).filter(requestContext);
+    }
+
+    /**
+     * A public path in V2 compatibility mode authenticates by default tenant, 
ignoring
+     * {@code Authorization} entirely — so a stray blank Basic header must not 
turn it into a 401.
+     */
+    @Test
+    void 
filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() 
throws IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        when(restAuthenticationConfig.getPublicPathPatterns())
+                .thenReturn(Collections.singletonList(Pattern.compile("POST 
context\\.json")));
+        
when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default");
+        ContainerRequestContext requestContext = request("context.json", 
basic("someone:"));
+
+        filter.filter(requestContext);
+
+        verify(tenantService).getTenant("default");
+    }
+
+    private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext 
requestContext) throws IOException {
+        ArgumentCaptor<Response> aborted = 
ArgumentCaptor.forClass(Response.class);
+        verify(requestContext).abortWith(aborted.capture());
+        assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), 
aborted.getValue().getStatus());
+        verify(jaasAuthenticationFilter, never()).filter(any());
+    }
+
+    /**
+     * The check has to run where a Basic credential is consumed, not once at 
the top of
+     * {@code filter()}. Anonymous traffic carrying a stray Basic header — a 
stale cached browser
+     * credential, an injecting proxy — must still authenticate by API key on 
the public path.
+     * <p>
+     * Asserted by observing that the public-path branch is still reached (the 
API key is looked up)
+     * rather than by asserting no abort: a public path whose API key does not 
resolve legitimately
+     * falls through and is refused for that unrelated reason, and 
authenticating one successfully
+     * needs a live CXF exchange, which is out of scope for a unit test.
+     */
+    @Test
+    void filterConsultsThePublicPathBranchDespiteAStrayBlankBasicHeader() 
throws IOException {
+        when(restAuthenticationConfig.getPublicPathPatterns())
+                .thenReturn(Collections.singletonList(Pattern.compile("POST 
context\\.json")));
+        ContainerRequestContext requestContext = request("context.json", 
basic("someone:"));
+
+        filter.filter(requestContext);
+
+        verify(tenantService).getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PUBLIC));
+    }
+
+    /** A public path with no Authorization header at all must equally reach 
the API-key lookup. */
+    @Test
+    void filterConsultsThePublicPathBranchForAnonymousRequests() throws 
IOException {
+        when(restAuthenticationConfig.getPublicPathPatterns())
+                .thenReturn(Collections.singletonList(Pattern.compile("POST 
context\\.json")));
+        ContainerRequestContext requestContext = request("context.json", null);
+
+        filter.filter(requestContext);
+
+        verify(tenantService).getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PUBLIC));
+    }
+
+    private ContainerRequestContext request(String path, String authHeader) {
+        ContainerRequestContext requestContext = 
mock(ContainerRequestContext.class);
+        UriInfo uriInfo = mock(UriInfo.class);
+        when(uriInfo.getPath()).thenReturn(path);
+        when(requestContext.getUriInfo()).thenReturn(uriInfo);
+        when(requestContext.getMethod()).thenReturn("POST");
+        
when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authHeader);
+        return requestContext;
+    }
+
+    private static String basic(String credentials) {
+        return "Basic " + 
Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+    }
+}
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
new file mode 100644
index 000000000..639c0096b
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
@@ -0,0 +1,418 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Ensures the distribution cannot start with a known or blank admin/health 
password.
+ * <p>
+ * The launcher guards are <em>executed</em> here rather than grepped: a check 
whose text is present
+ * but whose condition never matches would otherwise pass silently, which is 
exactly how the Windows
+ * {@code KARAF_SCRIPT} quoting bug survived review.
+ */
+class ShippedAdminPasswordConfigTest {
+
+    private static final String ROOT_PASSWORD_PROPERTY = 
"org.apache.unomi.security.root.password";
+    private static final String HEALTHCHECK_PASSWORD_PROPERTY = 
"org.apache.unomi.healthcheck.password";
+
+    /**
+     * The two files this test executes. They double as the fingerprint of the 
repository root (see
+     * {@link #locateRepoRoot()}): a directory containing both is the Unomi 
checkout, not some nested
+     * copy or a same-named directory further up the filesystem.
+     */
+    private static final String SETENV_PATH = 
"package/src/main/resources/bin/setenv";
+    private static final String ENTRYPOINT_PATH = 
"docker/src/main/docker/entrypoint.sh";
+
+    /** First line of the guard to copy out of entrypoint.sh. */
+    private static final String ENTRYPOINT_GUARD_START = 
"check_required_password()";
+    /** Last line of the guard; the slice is meaningless unless this is 
actually reached. */
+    private static final String ENTRYPOINT_GUARD_END = 
"UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1";
+
+    /** Default stub launcher name, mirroring {@code bin/karaf}. */
+    private static final String DEFAULT_KARAF_SCRIPT = "karaf";
+
+    private static final Path REPO_ROOT = locateRepoRoot();
+
+    // ---------------------------------------------------------------- 
shipped configuration
+
+    @Test
+    void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception {
+        String content = 
Files.readString(repoFile("package/src/main/resources/etc/users.properties"));
+
+        assertFalse(content.contains(":-karaf"), "users.properties must not 
default the karaf password to 'karaf'");
+        assertFalse(content.contains(":-health"), "users.properties must not 
default the health password to 'health'");
+        assertTrue(content.contains("${" + ROOT_PASSWORD_PROPERTY + "}"));
+        assertTrue(content.contains("${" + HEALTHCHECK_PASSWORD_PROPERTY + 
"}"));
+    }
+
+    @Test
+    void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() 
throws Exception {
+        List<String> securityLines = 
Files.readAllLines(repoFile("package/src/main/resources/etc/custom.system.properties"))
+                .stream()
+                .filter(line -> line.contains(ROOT_PASSWORD_PROPERTY) || 
line.contains(HEALTHCHECK_PASSWORD_PROPERTY))
+                .collect(Collectors.toList());
+
+        assertFalse(securityLines.isEmpty());
+        for (String line : securityLines) {
+            assertFalse(line.contains(":-karaf"), "root password must not fall 
back to karaf: " + line);
+            assertFalse(line.contains(":-health"), "health password must not 
fall back to health: " + line);
+        }
+    }
+
+
+    // ---------------------------------------------------------------- 
bin/setenv, executed
+
+    @Test
+    void setenv_refusesToStartWhenPasswordsMissing(@TempDir Path karafHome) 
throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Result missing = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new 
HashMap<>());
+        assertEquals(1, missing.exitCode, "setenv must refuse to start without 
passwords:\n" + missing.output);
+        assertFalse(missing.output.contains("LAUNCHED"), "the launcher must 
not be reached");
+        assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), 
missing.output);
+    }
+
+    /**
+     * The outer {@code case "${KARAF_SCRIPT}"} in bin/setenv lists every 
shipped script that boots a
+     * server: {@code bin/karaf}, {@code bin/start} and {@code bin/karaf 
server} all reach
+     * {@code org.apache.karaf.main.Main}. Only {@code karaf} used to be 
exercised here, so narrowing
+     * that list to a single entry would have gone unnoticed - now each entry 
has its own test case.
+     */
+    @ParameterizedTest(name = "KARAF_SCRIPT={0}")
+    @ValueSource(strings = {"karaf", "start", "server"})
+    void setenv_refusesToStartForEveryServerStartingScript(String karafScript, 
@TempDir Path tempDir) throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        Path karafHome = Files.createDirectories(tempDir.resolve(karafScript));
+        installFakeKarafHome(karafHome, karafScript, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Result missing = runLauncher(karafHome, karafScript, new HashMap<>());
+        assertEquals(1, missing.exitCode,
+                "bin/" + karafScript + " starts a server, so it must refuse to 
run without passwords:\n" + missing.output);
+        assertFalse(missing.output.contains("LAUNCHED"), "the launcher must 
not be reached for KARAF_SCRIPT=" + karafScript);
+        assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), 
missing.output);
+    }
+
+    /**
+     * The dedicated {@code bin/stop}, {@code bin/status}, {@code bin/client} 
and {@code bin/shell}
+     * scripts set {@code KARAF_SCRIPT} to their own name. None of them starts 
a server - they talk to
+     * an already running one - so the outer case must keep excluding them, 
otherwise stopping an
+     * instance would require the passwords used to start it.
+     */
+    @ParameterizedTest(name = "KARAF_SCRIPT={0}")
+    @ValueSource(strings = {"stop", "status", "client", "shell"})
+    void setenv_doesNotGuardScriptsThatOnlyTalkToARunningServer(String 
karafScript, @TempDir Path tempDir) throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        Path karafHome = Files.createDirectories(tempDir.resolve(karafScript));
+        installFakeKarafHome(karafHome, karafScript, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Result result = runLauncher(karafHome, karafScript, new HashMap<>());
+        assertEquals(0, result.exitCode, "bin/" + karafScript + " must not 
require the passwords:\n" + result.output);
+        assertTrue(result.output.contains("LAUNCHED"), result.output);
+    }
+
+    @Test
+    void setenv_startsWhenPasswordsProvided(@TempDir Path karafHome) throws 
Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Map<String, String> env = new HashMap<>();
+        env.put("UNOMI_ROOT_PASSWORD", "a-strong-password");
+        env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password");
+
+        Result provided = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env);
+        assertEquals(0, provided.exitCode, provided.output);
+        assertTrue(provided.output.contains("LAUNCHED"), provided.output);
+    }
+
+    /**
+     * The escape hatch only suppresses the environment-variable check. 
Configuring the property
+     * directly is a supported way to start, and must not be reported as an 
error.
+     */
+    @Test
+    void setenv_acceptsPasswordsConfiguredInPropertiesFile(@TempDir Path 
karafHome) throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"configured-root", "configured-health");
+
+        Result configured = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new 
HashMap<>());
+        assertEquals(0, configured.exitCode, configured.output);
+        assertTrue(configured.output.contains("LAUNCHED"), configured.output);
+    }
+
+    /**
+     * Claiming the password is set elsewhere, while leaving it blank 
everywhere, is the dangerous
+     * case: it must warn rather than pass silently.
+     */
+    @Test
+    void setenv_warnsWhenSkipFlagHidesABlankPassword(@TempDir Path karafHome) 
throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Map<String, String> env = new HashMap<>();
+        env.put("UNOMI_SKIP_ROOT_PASSWORD_CHECK", "true");
+        env.put("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK", "true");
+
+        Result skipped = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env);
+        assertEquals(0, skipped.exitCode, skipped.output);
+        assertTrue(skipped.output.contains("WARNING"), "a bypassed check must 
still warn:\n" + skipped.output);
+    }
+
+    /**
+     * {@code bin/karaf stop} and {@code bin/karaf status} keep {@code 
KARAF_SCRIPT=karaf} but replace
+     * the main class with {@code Main.Stop} / {@code Main.Status}: no server 
is started, so no
+     * password is needed. Requiring one would make an instance impossible to 
shut down cleanly from a
+     * shell that no longer has the startup environment.
+     */
+    @ParameterizedTest(name = "bin/karaf {0}")
+    @ValueSource(strings = {"stop", "status"})
+    void setenv_doesNotBlockSubcommandsThatDoNotStartAServer(String 
subcommand, @TempDir Path tempDir) throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        Path karafHome = Files.createDirectories(tempDir.resolve(subcommand));
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new 
HashMap<>(), subcommand);
+        assertEquals(0, result.exitCode, "'karaf " + subcommand + "' must not 
require the passwords:\n" + result.output);
+        assertTrue(result.output.contains("LAUNCHED"), result.output);
+    }
+
+    /**
+     * Regression guard for a real bug: the inner skip list once read {@code 
stop|status|client|shell},
+     * which silently disabled the password check for {@code bin/karaf client} 
and
+     * {@code bin/karaf shell}. Those subcommands are <em>not</em> the {@code 
bin/client} /
+     * {@code bin/shell} remote consoles - the karaf script does not 
special-case them, so they fall
+     * through to {@code org.apache.karaf.main.Main} and boot a complete 
server, admin account
+     * included. The same is true of any argument the script does not 
recognise. Only {@code stop} and
+     * {@code status} may skip the check; everything else here must still be 
refused.
+     */
+    @ParameterizedTest(name = "bin/karaf {0}")
+    @ValueSource(strings = {"client", "shell", "console", 
"--an-argument-karaf-does-not-know"})
+    void setenv_stillGuardsSubcommandsThatBootAFullServer(String subcommand, 
@TempDir Path tempDir) throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        Path karafHome = 
Files.createDirectories(tempDir.resolve(subcommand.replace("-", "_")));
+        installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, 
"${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+        Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new 
HashMap<>(), subcommand);
+        assertEquals(1, result.exitCode,
+                "'karaf " + subcommand + "' starts a full server, so it must 
refuse to run without passwords:\n" + result.output);
+        assertFalse(result.output.contains("LAUNCHED"), "the launcher must not 
be reached for 'karaf " + subcommand + "'");
+        assertTrue(result.output.contains("UNOMI_ROOT_PASSWORD is not set"), 
result.output);
+    }
+
+    // ---------------------------------------------------------------- docker 
entrypoint, executed
+
+    @Test
+    void entrypoint_refusesToStartWhenPasswordsMissing(@TempDir Path workDir) 
throws Exception {
+        assumeTrue(hasPosixShell(), "requires a POSIX shell");
+        Path guard = extractEntrypointPasswordGuard(workDir);
+
+        assertEquals(1, runShell(guard, workDir, new HashMap<>()).exitCode,
+                "the Docker entrypoint must exit non-zero without passwords");
+
+        Map<String, String> env = new HashMap<>();
+        env.put("UNOMI_ROOT_PASSWORD", "a-strong-password");
+        env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password");
+        Result provided = runShell(guard, workDir, env);
+        assertEquals(0, provided.exitCode, provided.output);
+        assertTrue(provided.output.contains("GUARD-PASSED"), provided.output);
+    }
+
+    // ---------------------------------------------------------------- 
setenv.bat regression guard
+
+    /**
+     * {@code karaf.bat} sets {@code KARAF_SCRIPT} with the quotes included in 
the value
+     * ({@code SET KARAF_SCRIPT="karaf.bat"}), so comparing {@code 
"%KARAF_SCRIPT%"} against
+     * {@code "karaf.bat"} never matches and the whole check is skipped. The 
quotes must be stripped
+     * before comparing. This cannot be executed on a POSIX CI machine, so 
assert the shape instead.
+     */
+    @Test
+    void setenvBat_stripsQuotesBeforeComparingKarafScript() throws Exception {
+        String content = 
Files.readString(repoFile("package/src/main/resources/bin/setenv.bat"));
+
+        assertTrue(content.contains("%KARAF_SCRIPT:\"=%"),
+                "setenv.bat must strip the quotes karaf.bat embeds in 
KARAF_SCRIPT");
+        assertFalse(content.contains("\"%KARAF_SCRIPT%\"==\"karaf.bat\""),
+                "comparing the raw KARAF_SCRIPT against karaf.bat never 
matches");
+        assertTrue(content.contains("UNOMI_ROOT_PASSWORD") && 
content.contains("UNOMI_HEALTHCHECK_PASSWORD"),
+                "setenv.bat must check both passwords");
+    }
+
+    // ---------------------------------------------------------------- helpers
+
+    /**
+     * Finds the repository root by walking up from the working directory 
until an ancestor holds
+     * <em>both</em> shipped launcher scripts this test executes. Matching on 
a single relative path
+     * would let a nested checkout (or any unrelated directory that happens to 
contain a
+     * {@code package/} tree) win, and the test would then silently assert 
against the wrong sources.
+     */
+    private static Path locateRepoRoot() {
+        Path start = Paths.get("").toAbsolutePath().normalize();
+        for (Path candidate = start; candidate != null; candidate = 
candidate.getParent()) {
+            if (Files.isRegularFile(candidate.resolve(SETENV_PATH))
+                    && 
Files.isRegularFile(candidate.resolve(ENTRYPOINT_PATH))) {
+                return candidate;
+            }
+        }
+        throw new IllegalStateException("Could not locate the Unomi repository 
root from " + start
+                + " (looked for an ancestor containing both " + SETENV_PATH + 
" and " + ENTRYPOINT_PATH + ")");
+    }
+
+    /**
+     * Resolves a repository-relative path against the detected repository 
root, so the test works
+     * regardless of which module directory the build runs it from.
+     */
+    private static Path repoFile(String relativePath) {
+        Path resolved = REPO_ROOT.resolve(relativePath);
+        if (!Files.exists(resolved)) {
+            throw new IllegalStateException("Could not locate " + relativePath 
+ " under repository root " + REPO_ROOT);
+        }
+        return resolved;
+    }
+
+    private static boolean hasPosixShell() {
+        return !System.getProperty("os.name", 
"").toLowerCase().contains("win");
+    }
+
+    /**
+     * Builds a throwaway Karaf layout containing the real {@code bin/setenv} 
plus a stub launcher
+     * that mimics how the shipped scripts source it. {@code karafScript} 
names the stub and the value
+     * it exports as {@code KARAF_SCRIPT}, so tests can reproduce {@code 
bin/karaf}, {@code bin/start},
+     * {@code bin/stop}, ... rather than only ever exercising {@code karaf}.
+     */
+    private static void installFakeKarafHome(Path karafHome, String 
karafScript, String rootPassword, String healthPassword)
+            throws IOException {
+        Path bin = Files.createDirectories(karafHome.resolve("bin"));
+        Path etc = Files.createDirectories(karafHome.resolve("etc"));
+
+        Files.copy(repoFile(SETENV_PATH), bin.resolve("setenv"));
+        Files.write(etc.resolve("custom.system.properties"),
+                (ROOT_PASSWORD_PROPERTY + "=" + rootPassword + "\n"
+                        + HEALTHCHECK_PASSWORD_PROPERTY + "=" + healthPassword 
+ "\n").getBytes(StandardCharsets.UTF_8));
+
+        // Mirrors apache-karaf/bin/<script>: export KARAF_SCRIPT, then source 
setenv at top level.
+        Path launcher = bin.resolve(karafScript);
+        Files.write(launcher, ("#!/bin/sh\n"
+                + "KARAF_SCRIPT=\"" + karafScript + "\"\n"
+                + "export KARAF_SCRIPT\n"
+                + ". \"$(dirname \"$0\")/setenv\"\n"
+                + "echo LAUNCHED\n").getBytes(StandardCharsets.UTF_8));
+        launcher.toFile().setExecutable(true);
+    }
+
+    /**
+     * Copies the entrypoint's password guard into a standalone script, so the 
test does not have to
+     * run the rest of the container bootstrap (which needs a search engine).
+     */
+    private static Path extractEntrypointPasswordGuard(Path workDir) throws 
IOException {
+        List<String> lines = Files.readAllLines(repoFile(ENTRYPOINT_PATH));
+        StringBuilder guard = new StringBuilder("#!/bin/sh\n");
+        boolean capturing = false;
+        boolean terminated = false;
+        for (String line : lines) {
+            if (line.startsWith(ENTRYPOINT_GUARD_START)) {
+                capturing = true;
+            }
+            if (capturing) {
+                guard.append(line).append('\n');
+            }
+            if (capturing && line.contains(ENTRYPOINT_GUARD_END)) {
+                terminated = true;
+                break;
+            }
+        }
+        assertTrue(capturing, "entrypoint.sh must define the guard; start 
marker '" + ENTRYPOINT_GUARD_START
+                + "' was not found in " + ENTRYPOINT_PATH);
+        // Without this, a reformatted entrypoint.sh would make the slice run 
to end-of-file: the test
+        // would still "pass" while executing something entirely different 
from the guard.
+        assertTrue(terminated, "the guard slice never reached its terminating 
line '" + ENTRYPOINT_GUARD_END
+                + "' in " + ENTRYPOINT_PATH + " - update the marker, this test 
is no longer testing the guard");
+        guard.append("echo GUARD-PASSED\n");
+
+        Path script = workDir.resolve("entrypoint-guard.sh");
+        Files.write(script, guard.toString().getBytes(StandardCharsets.UTF_8));
+        return script;
+    }
+
+    private static Result runLauncher(Path karafHome, String karafScript, 
Map<String, String> env, String... args) throws Exception {
+        String[] command = new String[args.length + 2];
+        command[0] = "/bin/sh";
+        command[1] = karafHome.resolve("bin").resolve(karafScript).toString();
+        System.arraycopy(args, 0, command, 2, args.length);
+        return run(new ProcessBuilder(command).directory(karafHome.toFile()), 
env);
+    }
+
+    private static Result runShell(Path script, Path workDir, Map<String, 
String> env) throws Exception {
+        return run(new ProcessBuilder("/bin/sh", 
script.toString()).directory(workDir.toFile()), env);
+    }
+
+    private static Result run(ProcessBuilder builder, Map<String, String> env) 
throws Exception {
+        Map<String, String> environment = builder.environment();
+
+        // Inherit the JVM's environment - bin/setenv shells out to dirname, 
sed, grep and tail, which
+        // do not live under /usr/bin:/bin on every platform (NixOS, minimal 
images), and a hardcoded
+        // PATH turns that into a confusing failure instead of a working test.
+        //
+        // But strip every UNOMI_* variable first: an ambient 
UNOMI_ROOT_PASSWORD on a developer's
+        // machine would otherwise satisfy the "missing password" cases and 
make them pass by accident,
+        // which is the one property these tests cannot afford to lose. 
KARAF_* goes too, because
+        // KARAF_ETC would redirect the script at the developer's own etc/ 
directory.
+        List<String> inherited = new ArrayList<>(environment.keySet());
+        for (String name : inherited) {
+            if (name.startsWith("UNOMI_") || name.startsWith("KARAF_")) {
+                environment.remove(name);
+            }
+        }
+        environment.putIfAbsent("PATH", "/usr/bin:/bin");
+        environment.putAll(env);
+        builder.redirectErrorStream(true);
+
+        Process process = builder.start();
+        String output = new String(process.getInputStream().readAllBytes(), 
StandardCharsets.UTF_8);
+        assertTrue(process.waitFor(60, TimeUnit.SECONDS), "script did not 
terminate");
+        return new Result(process.exitValue(), output);
+    }
+
+    private static final class Result {
+        private final int exitCode;
+        private final String output;
+
+        private Result(int exitCode, String output) {
+            this.exitCode = exitCode;
+            this.output = output;
+        }
+    }
+}
diff --git a/setup-elasticsearch.sh b/setup-elasticsearch.sh
index 8c103668f..f794801ce 100755
--- a/setup-elasticsearch.sh
+++ b/setup-elasticsearch.sh
@@ -56,17 +56,15 @@ _setup_elasticsearch() {
         return 1
     fi
 
-    # Load only the Elasticsearch password from .env.local if it exists
-    # This ensures we don't load the OpenSearch password
-    if ! load_password_from_env_local "${SCRIPT_DIR}" 
"UNOMI_ELASTICSEARCH_PASSWORD"; then
-        # If not found in .env.local, check if it's already set in environment
-        if [ -z "${UNOMI_ELASTICSEARCH_PASSWORD}" ]; then
-            echo "Note: UNOMI_ELASTICSEARCH_PASSWORD not found in .env.local 
or environment"
-        fi
+    # Load required passwords from .env.local (or existing environment).
+    # Engine password is Elasticsearch-only so we do not pull OpenSearch's 
password.
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then
+        return 1
     fi
-
-    # Check if password is set
-    if ! check_password "UNOMI_ELASTICSEARCH_PASSWORD"; then
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then
+        return 1
+    fi
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then
         return 1
     fi
 
@@ -76,7 +74,6 @@ _setup_elasticsearch() {
     export UNOMI_ELASTICSEARCH_USERNAME=elastic
     export UNOMI_ELASTICSEARCH_SSL_ENABLE=true
     export UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES=true
-    # Password is already set from .env.local or environment
 
     # Set the distribution to use Elasticsearch (default, but explicit for 
clarity)
     export UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
@@ -87,6 +84,9 @@ _setup_elasticsearch() {
     echo "  Username: ${UNOMI_ELASTICSEARCH_USERNAME} (overridden from 
default: empty)"
     echo "  SSL Enabled: ${UNOMI_ELASTICSEARCH_SSL_ENABLE} (overridden from 
default: false)"
     echo "  Trust All Certificates: 
${UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES} (overridden from default: 
false)"
+    echo "  Elasticsearch password: (set from .env.local or environment)"
+    echo "  Root password: (set from .env.local or environment)"
+    echo "  Healthcheck password: (set from .env.local or environment)"
     
     return 0
 }
diff --git a/setup-opensearch.sh b/setup-opensearch.sh
index 565dcc4e9..995ad1b2a 100755
--- a/setup-opensearch.sh
+++ b/setup-opensearch.sh
@@ -56,30 +56,26 @@ _setup_opensearch() {
         return 1
     fi
 
-    # Load only the OpenSearch password from .env.local if it exists
-    # This ensures we don't load the Elasticsearch password
-    if ! load_password_from_env_local "${SCRIPT_DIR}" 
"UNOMI_OPENSEARCH_PASSWORD"; then
-        # If not found in .env.local, check if it's already set in environment
-        if [ -z "${UNOMI_OPENSEARCH_PASSWORD}" ]; then
-            echo "Note: UNOMI_OPENSEARCH_PASSWORD not found in .env.local or 
environment"
-        fi
+    # Load required passwords from .env.local (or existing environment).
+    # Engine password is OpenSearch-only so we do not pull Elasticsearch's 
password.
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then
+        return 1
     fi
-
-    # Check if password is set
-    if ! check_password "UNOMI_OPENSEARCH_PASSWORD"; then
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then
+        return 1
+    fi
+    if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then
         return 1
     fi
-
-    # Set OpenSearch 3 password (only override needed - defaults are 
appropriate for OpenSearch 3)
-    # Password is already set from .env.local or environment, just ensure it's 
exported
-    export UNOMI_OPENSEARCH_PASSWORD
 
     # Set the distribution to use OpenSearch
     export UNOMI_DISTRIBUTION=unomi-distribution-opensearch
 
     echo "OpenSearch 3 environment variables configured."
     echo "  Distribution: ${UNOMI_DISTRIBUTION}"
-    echo "  Password: (set from .env.local or environment)"
+    echo "  OpenSearch password: (set from .env.local or environment)"
+    echo "  Root password: (set from .env.local or environment)"
+    echo "  Healthcheck password: (set from .env.local or environment)"
     echo "  Note: Using Unomi defaults for other OpenSearch settings (cluster, 
addresses, username, SSL)"
     
     return 0
diff --git a/setup-utils.sh b/setup-utils.sh
index 6d8f04338..fd82fb0c2 100755
--- a/setup-utils.sh
+++ b/setup-utils.sh
@@ -139,6 +139,33 @@ check_password() {
     return 0
 }
 
+# Load a password from .env.local (if present) and require it to be set.
+# Usage: require_password SCRIPT_DIR PASSWORD_VAR_NAME
+# Returns: 0 if set, 1 if missing
+require_password() {
+    local script_dir="$1"
+    local password_var="$2"
+
+    if ! load_password_from_env_local "${script_dir}" "${password_var}"; then
+        if [ -n "${ZSH_VERSION}" ]; then
+            local password_value="${(P)password_var}"
+        else
+            local password_value="${!password_var}"
+        fi
+        if [ -z "${password_value}" ]; then
+            echo "Note: ${password_var} not found in .env.local or environment"
+        fi
+    fi
+
+    if ! check_password "${password_var}"; then
+        return 1
+    fi
+
+    # Ensure the variable is exported into the current shell
+    eval "export ${password_var}"
+    return 0
+}
+
 # Clear the opposite search engine's environment variables
 # Usage: clear_opposite SCRIPT_DIR OPPOSITE_TYPE
 # OPPOSITE_TYPE should be "opensearch" or "elasticsearch"

Reply via email to