diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs
new file mode 100644
index 00000000..5b2a81d4
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs
@@ -0,0 +1,222 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ZB.MOM.WW.ScadaBridge.ManagementService;
+
+///
+/// Elides secret-bearing values inside opaque connection-config JSON blobs before
+/// they leave the management boundary (responses AND audit afterState), mirroring
+/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities.
+/// A property is secret-bearing when its name contains one of the fragments below
+/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client
+/// round-trip a scrubbed config through update without wiping stored secrets.
+///
+internal static class ConfigSecretScrubber
+{
+ public const string Sentinel = "***REDACTED***";
+
+ private static readonly string[] SecretNameFragments =
+ ["password", "secret", "token", "apikey", "credential", "passphrase"];
+
+ private static bool IsSecretName(string propertyName)
+ {
+ foreach (var fragment in SecretNameFragments)
+ {
+ if (propertyName.Contains(fragment, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Replaces every secret-bearing string value (at any depth) with .
+ /// Null/empty/parse-failure inputs are returned unchanged with hadSecrets=false.
+ ///
+ public static (string? scrubbed, bool hadSecrets) Scrub(string? json)
+ {
+ if (string.IsNullOrEmpty(json))
+ {
+ return (json, false);
+ }
+
+ JsonNode? root;
+ try
+ {
+ root = JsonNode.Parse(json);
+ }
+ catch (JsonException)
+ {
+ return (json, false);
+ }
+
+ if (root is null)
+ {
+ return (json, false);
+ }
+
+ var hadSecrets = ScrubNode(root, propertyIsSecret: false);
+ if (!hadSecrets)
+ {
+ return (json, false);
+ }
+
+ return (root.ToJsonString(), true);
+ }
+
+ private static bool ScrubNode(JsonNode? node, bool propertyIsSecret)
+ {
+ switch (node)
+ {
+ case JsonObject obj:
+ {
+ var had = false;
+ // Snapshot keys so we can reassign values while iterating.
+ foreach (var name in obj.Select(kvp => kvp.Key).ToList())
+ {
+ var child = obj[name];
+ if (child is JsonValue && IsSecretName(name) && child.GetValueKind() == JsonValueKind.String)
+ {
+ obj[name] = Sentinel;
+ had = true;
+ }
+ else
+ {
+ had |= ScrubNode(child, IsSecretName(name));
+ }
+ }
+
+ return had;
+ }
+
+ case JsonArray arr:
+ {
+ var had = false;
+ for (var i = 0; i < arr.Count; i++)
+ {
+ var child = arr[i];
+ if (propertyIsSecret && child is JsonValue && child.GetValueKind() == JsonValueKind.String)
+ {
+ arr[i] = Sentinel;
+ had = true;
+ }
+ else
+ {
+ had |= ScrubNode(child, propertyIsSecret);
+ }
+ }
+
+ return had;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Walks the incoming config; wherever a string value equals ,
+ /// substitutes the value at the same path in (if absent, the
+ /// Sentinel is left in place — an update would store it verbatim, which is inert).
+ /// Null/empty/parse-failure inputs pass through unchanged.
+ ///
+ public static string? MergeSentinels(string? incoming, string? stored)
+ {
+ if (string.IsNullOrEmpty(incoming))
+ {
+ return incoming;
+ }
+
+ JsonNode? incomingRoot;
+ try
+ {
+ incomingRoot = JsonNode.Parse(incoming);
+ }
+ catch (JsonException)
+ {
+ return incoming;
+ }
+
+ if (incomingRoot is null)
+ {
+ return incoming;
+ }
+
+ JsonNode? storedRoot = null;
+ if (!string.IsNullOrEmpty(stored))
+ {
+ try
+ {
+ storedRoot = JsonNode.Parse(stored);
+ }
+ catch (JsonException)
+ {
+ storedRoot = null;
+ }
+ }
+
+ MergeNode(incomingRoot, storedRoot);
+ return incomingRoot.ToJsonString();
+ }
+
+ private static void MergeNode(JsonNode? incoming, JsonNode? stored)
+ {
+ switch (incoming)
+ {
+ case JsonObject obj:
+ {
+ var storedObj = stored as JsonObject;
+ foreach (var name in obj.Select(kvp => kvp.Key).ToList())
+ {
+ var child = obj[name];
+ JsonNode? storedChild = null;
+ storedObj?.TryGetPropertyValue(name, out storedChild);
+
+ if (child is JsonValue
+ && child.GetValueKind() == JsonValueKind.String
+ && child.GetValue() == Sentinel)
+ {
+ if (storedChild is not null)
+ {
+ obj[name] = storedChild.DeepClone();
+ }
+ }
+ else
+ {
+ MergeNode(child, storedChild);
+ }
+ }
+
+ break;
+ }
+
+ case JsonArray arr:
+ {
+ var storedArr = stored as JsonArray;
+ for (var i = 0; i < arr.Count; i++)
+ {
+ var child = arr[i];
+ var storedChild = storedArr is not null && i < storedArr.Count ? storedArr[i] : null;
+
+ if (child is JsonValue
+ && child.GetValueKind() == JsonValueKind.String
+ && child.GetValue() == Sentinel)
+ {
+ if (storedChild is not null)
+ {
+ arr[i] = storedChild.DeepClone();
+ }
+ }
+ else
+ {
+ MergeNode(child, storedChild);
+ }
+ }
+
+ break;
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj
index 56a0bf81..47acc975 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj
@@ -8,6 +8,9 @@
+
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs
new file mode 100644
index 00000000..75e65e75
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs
@@ -0,0 +1,45 @@
+namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
+
+public class ConfigSecretScrubberTests
+{
+ [Fact]
+ public void Scrub_RedactsPasswordFields_AtAnyDepth()
+ {
+ var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}""";
+ var (scrubbed, had) = ConfigSecretScrubber.Scrub(json);
+ Assert.True(had);
+ Assert.DoesNotContain("\"p\"", scrubbed);
+ Assert.DoesNotContain("\"cp\"", scrubbed);
+ Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed);
+ Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
+ }
+
+ [Fact]
+ public void Scrub_NoSecrets_ReturnsFalseFlag()
+ {
+ var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}""");
+ Assert.False(had);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not-json{")]
+ public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input)
+ {
+ var (scrubbed, had) = ConfigSecretScrubber.Scrub(input);
+ Assert.Equal(input, scrubbed);
+ Assert.False(had);
+ }
+
+ [Fact]
+ public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
+ {
+ var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}""";
+ var incoming = $$$"""{"Endpoint":"new","UserIdentity":{"Password":"{{{ConfigSecretScrubber.Sentinel}}}"}}""";
+ var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!;
+ Assert.Contains("\"new\"", merged);
+ Assert.Contains("\"real\"", merged);
+ Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
+ }
+}