← Back to Blog

The appsettings Mistake That Can Expose a Staging Connection String

A plaintext credential in an appsettings.Staging.json file ended up somewhere it should never have been. Here's the exact .NET publish mechanics that caused it, and the three fixes that close the gap.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work β€” not documentation summaries.
Published 28 Jul 2026· Last reviewed Jul 2026· 7 min read · About the author →
Senior .NET engineer working across ASP.NET Core APIs, EF Core, and Azure deployments.
Key takeaways
  • dotnet publish copies every appsettings*.json file matching the SDK's default content glob into the publish output, including environment-specific files you didn't mean to ship
  • A connection string with User Id=/Password= in ANY tracked appsettings file is a plaintext credential the moment it's committed, regardless of which environment name is in the filename
  • CopyToPublishDirectory=Never on a specific Content item is the fix for files that must exist locally but never leave the repo
  • dotnet user-secrets, Azure Key Vault, and App Service Application Settings exist specifically so a real credential never has to live in a tracked file at all
  • A structural diff between environment config files surfaces a stray plaintext credential before it's committed, not after
Table of Contents
πŸ“… Finished and scheduled β€” not yet linked from the blog index or sitemap. Publishing around 23 Jul 2026.

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.

Try the free tool

appsettings.json Environment Diff

Paste two appsettings.json files side by side and see every structural difference — including a flag for plaintext credentials sitting in a ConnectionStrings section.

Open appsettings.json Environment Diff