diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs
new file mode 100644
index 0000000..2edf0ac
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs
@@ -0,0 +1,109 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Configuration;
+
+///
+/// Expands ${secret:name} reference tokens in configuration values by resolving each
+/// referenced secret through an . Expansion is fail-closed:
+/// a token that names an absent secret throws rather than
+/// leaving the placeholder (or a blank) in place, so a misconfigured deployment fails loudly at
+/// startup instead of silently running with an empty credential.
+///
+public sealed class SecretReferenceExpander
+{
+ private const string TokenMarker = "${secret:";
+
+ private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);
+
+ private readonly ISecretResolver _resolver;
+
+ /// Creates an expander that resolves references via .
+ /// The resolver used to fetch each referenced secret's plaintext.
+ public SecretReferenceExpander(ISecretResolver resolver) =>
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+
+ ///
+ /// Replaces every ${secret:name} token in with the resolved
+ /// plaintext of the named secret. Multiple tokens (including repeats of the same name) are all
+ /// expanded; each distinct name is resolved once.
+ ///
+ /// The raw configuration value, possibly containing reference tokens.
+ /// A token to cancel the operation.
+ ///
+ /// The expanded value, or unchanged when it is null or contains
+ /// no tokens.
+ ///
+ /// A referenced secret does not exist (fail-closed).
+ public async Task ExpandAsync(string raw, CancellationToken ct)
+ {
+ if (raw is null)
+ {
+ return raw!;
+ }
+
+ MatchCollection matches = TokenPattern.Matches(raw);
+ if (matches.Count == 0)
+ {
+ return raw;
+ }
+
+ // Resolve each distinct referenced name exactly once, then substitute synchronously.
+ var resolved = new Dictionary(StringComparer.Ordinal);
+ foreach (Match match in matches)
+ {
+ string name = match.Groups[1].Value.Trim();
+ if (resolved.ContainsKey(name))
+ {
+ continue;
+ }
+
+ string? value = await _resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
+ if (value is null)
+ {
+ throw new SecretNotFoundException(
+ $"Configuration references unknown secret '{name}' via ${{secret:{name}}}.");
+ }
+
+ resolved[name] = value;
+ }
+
+ return TokenPattern.Replace(raw, m => resolved[m.Groups[1].Value.Trim()]);
+ }
+
+ ///
+ /// Walks every entry in and rewrites, in place, any value that
+ /// contains a ${secret:...} token to its expanded plaintext. Writes go through the
+ /// indexer so subsequent reads — and any options binding done
+ /// afterward — observe the expanded values.
+ ///
+ ///
+ /// This MUST run after configuration is loaded and before options validation
+ /// or ValidateOnStart executes; otherwise validators may see the unexpanded placeholder.
+ /// Fail-closed: a missing referenced secret propagates out
+ /// of startup.
+ ///
+ /// The loaded configuration root to mutate.
+ /// A token to cancel the operation.
+ /// A referenced secret does not exist (fail-closed).
+ public async Task ExpandConfigurationAsync(IConfigurationRoot config, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+
+ // Materialize first: mutating provider data while enumerating AsEnumerable() is unsafe.
+ var pending = new List>();
+ foreach (KeyValuePair entry in config.AsEnumerable())
+ {
+ if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal))
+ {
+ pending.Add(new KeyValuePair(entry.Key, value));
+ }
+ }
+
+ foreach (KeyValuePair entry in pending)
+ {
+ config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
index 450ec10..759ce67 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
@@ -11,6 +11,7 @@
+
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs
new file mode 100644
index 0000000..dd868f8
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs
@@ -0,0 +1,106 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Configuration;
+
+namespace ZB.MOM.WW.Secrets.Tests.Configuration;
+
+public sealed class SecretReferenceExpanderTests
+{
+ private sealed class FakeSecretResolver : ISecretResolver
+ {
+ private readonly Dictionary _values;
+
+ public FakeSecretResolver(Dictionary values) => _values = values;
+
+ public Task GetAsync(SecretName name, CancellationToken ct) =>
+ Task.FromResult(_values.TryGetValue(name.Value, out string? v) ? v : null);
+ }
+
+ private static SecretReferenceExpander Expander(params (string Name, string? Value)[] secrets)
+ {
+ var dict = new Dictionary();
+ foreach ((string name, string? value) in secrets)
+ {
+ dict[new SecretName(name).Value] = value;
+ }
+
+ return new SecretReferenceExpander(new FakeSecretResolver(dict));
+ }
+
+ [Fact]
+ public async Task ExpandAsync_SingleToken()
+ {
+ SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
+
+ string result = await expander.ExpandAsync("Password=${secret:sql/foo}", CancellationToken.None);
+
+ Assert.Equal("Password=hunter2", result);
+ }
+
+ [Fact]
+ public async Task ExpandAsync_MultipleTokens()
+ {
+ SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"), ("sql/bar", "swordfish"));
+
+ string result = await expander.ExpandAsync(
+ "u=${secret:sql/foo};p=${secret:sql/bar}", CancellationToken.None);
+
+ Assert.Equal("u=hunter2;p=swordfish", result);
+ }
+
+ [Fact]
+ public async Task ExpandAsync_NoToken_Unchanged()
+ {
+ SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
+
+ string result = await expander.ExpandAsync("no tokens here", CancellationToken.None);
+
+ Assert.Equal("no tokens here", result);
+ }
+
+ [Fact]
+ public async Task ExpandAsync_MissingSecret_Throws()
+ {
+ SecretReferenceExpander expander = Expander();
+
+ SecretNotFoundException ex = await Assert.ThrowsAsync(
+ () => expander.ExpandAsync("Password=${secret:sql/absent}", CancellationToken.None));
+
+ Assert.Contains("sql/absent", ex.Message);
+ Assert.Contains("${secret:sql/absent}", ex.Message);
+ }
+
+ [Fact]
+ public async Task ExpandConfigurationAsync_RewritesMatchingKeys()
+ {
+ SecretReferenceExpander expander = Expander(("sql/foo", "hunter2"));
+ IConfigurationRoot config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}",
+ ["Plain:Value"] = "unchanged",
+ })
+ .Build();
+
+ await expander.ExpandConfigurationAsync(config, CancellationToken.None);
+
+ Assert.Contains("hunter2", config["ConnectionStrings:Db"]);
+ Assert.DoesNotContain("${secret:", config["ConnectionStrings:Db"]);
+ Assert.Equal("unchanged", config["Plain:Value"]);
+ }
+
+ [Fact]
+ public async Task ExpandConfigurationAsync_MissingSecret_FailsClosed()
+ {
+ SecretReferenceExpander expander = Expander();
+ IConfigurationRoot config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/absent}",
+ })
+ .Build();
+
+ await Assert.ThrowsAsync(
+ () => expander.ExpandConfigurationAsync(config, CancellationToken.None));
+ }
+}