The incident
Here's the typical way this surfaces: a team spins up a staging environment ahead of a demo, pastes a real (if lower-privilege) SQL Server connection string into appsettings.Staging.json to save time, and moves on. Weeks later, a routine dotnet publish for an unrelated hotfix picks up that file β along with every other appsettings*.json the SDK's default glob matches β and ships it inside the deployment package. The credential isn't discovered until someone reviews a build artifact for an unrelated audit and finds a working database password sitting in plain text, several releases after it was first added.
How a connection string ends up somewhere it shouldn't
The mechanism that makes this bug so easy to ship without noticing is a default behavior of the .NET SDK itself, not a mistake in application code. Every appsettings*.json file in a project directory matches the SDK's default content item glob and is copied into the publish output by dotnet publish β including files you added for a specific environment and never intended to leave your machine or your CI pipeline:
// A typical project layout β all four files below are picked up by the default glob
// appsettings.json
// appsettings.Development.json
// appsettings.Staging.json <-- this one had a real staging connection string
// appsettings.Production.json
// dotnet publish output (bin/Release/net8.0/publish/) contains ALL FOUR,
// regardless of which ASPNETCORE_ENVIRONMENT the app actually runs under.
// The environment name in the filename controls which file is LOADED at runtime β
// it does nothing to control which files are COPIED at publish time.
If appsettings.Staging.json contains a real, working connection string β because it was easiest to just paste one in while setting up a staging slot β that string ships inside every published artifact from that point on, whether it's a container image, a zip deploy package, or a build published to a shared drop location. Anyone with access to the artifact has the credential, independent of whether they have access to the staging environment itself.
Why this is easy to miss in review
A pull request that adds or edits appsettings.Staging.json looks exactly like any other config change in the diff view β GitHub doesn't flag a Password= value differently from a LogLevel value. Unless a reviewer is specifically scanning for credential-shaped strings, a plaintext password reads as just another line of JSON. This is exactly the class of gap the appsettings.json Environment Diff tool's plaintext-credential check exists for β it flags any parsed connection string containing Password, Pwd, or AccountKey as a visible warning rather than relying on a human catching it by eye.
The fix: stop the file from being publishable at all
The most direct fix targets the actual mechanism β the publish-time copy β rather than relying on nobody ever adding a real secret to a tracked file. Overriding the content item's CopyToPublishDirectory metadata for a specific file stops it from shipping, while still letting it exist locally for development:
<!-- .csproj -->
<ItemGroup>
<Content Update="appsettings.Staging.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
<!--
CopyToPublishDirectory accepts: Always, PreserveNewest, Never.
"Never" means the file still builds and runs locally (it's still on disk),
but dotnet publish will not include it in the output β so even if a real
secret ends up in this file again by accident, it can no longer leak
through a publish artifact.
-->
The fix that removes the temptation entirely
The CopyToPublishDirectory fix stops the leak but doesn't stop the next person from pasting a real credential into a tracked file, because the file still exists and still gets loaded locally. The complete fix is to never let a real secret live in a tracked appsettings*.json file in the first place:
// Local development β dotnet user-secrets stores the value outside the repo entirely,
// in a per-project file under your user profile, never committed, never published.
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=staging-sql.example.com;Database=AppDb;User Id=app_user;Password=REAL_VALUE;"
// appsettings.Staging.json now only needs the non-secret shape, if anything at all:
{
"ConnectionStrings": {
"Default": ""
}
}
// Staging and Production themselves: Azure Key Vault via configuration provider,
// or App Service Application Settings, both of which inject the real value at
// runtime without it ever touching a file that could be committed or published.
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());
What to check right now
Run git log -p -- appsettings.Staging.json appsettings.Production.json (or the equivalent for any environment file in your repo) and look at every historical version, not just the current one β a credential that was removed from the latest commit is still sitting in your git history and remains exposed to anyone with clone access, unless it's been rotated. Rotating the actual credential is the only complete fix once a real secret has been committed even once; removing it from a future commit does not undo the exposure.
Related to this: a missing config key between Development and Production is the more common variant of appsettings drift, and connection string gotchas in .NET 8 covers other ways a connection string misconfiguration surfaces at runtime.