Updated on

ASP.NET Core gives us two ways to keep secrets out of source control on a development machine: user secrets, stored per project in a file outside the repository, and environment variables, stored per machine or per shell session. Both feed straight into IConfiguration, so the code reading them does not change.

Neither one encrypts anything. They solve a narrower problem than that, keeping a connection string out of a commit and out of a colleague’s way, and production secrets need a real vault instead.

The previous part built a custom configuration provider backed by EF Core, and that provider needs a connection string before it can read anything at all. This part is about where that connection string goes.

To download the source code for this article, you can visit the SecuringDataLocally folder in our GitHub repository. The source code for the whole series is here.

Let’s dive in.

What Counts as Sensitive Data in an ASP.NET Core App?

Anything that would let someone else act as our application counts: connection strings, API keys, tokens, passwords and password hashes, signing keys, and certificate files.

The test is not whether a value looks secret. It is what someone could do with it. A database server name is harmless on its own and dangerous next to a password, so the pair travels together and both stay out of the repository.

Configuration files are the trap, because appsettings.json is committed by default and feels like the natural home for a connection string. It is committed the moment it works, and commit history keeps it after the value is removed.

The cost is not theoretical. Cleaning up means revoking keys and rotating passwords, not deleting a line, because anyone who cloned the repository already has the old value.

That is why both mechanisms in this article store values outside the project directory rather than encrypting them inside it.

It’s pretty much impossible that you’ve done any serious software development without leaving a sensitive piece of information somewhere in the commit history.

Whether we know it or not.

We know we have, and it’s pretty easy to fall for that trap. You’re working on your side project, a quick little proof of concept that will show off your latest idea. In order to create it, you need access to the database, and you quickly create one in Azure/AWS/GCloud. You put the connection string in appsettings.json quickly, just to test it out. A few hours of development later, you are pretty happy with how your project turned out, and since you don’t want to lose that, you quickly push it to the GitHub.

A few years later, you browse your repositories on the GitHub to see how much you’ve advanced and everything you did over the years. You open the project and find out the committed connection string, server name, password, and everything.

Sounds familiar?

Where Do We Keep Secrets During Development?

Outside the project directory, in one of two places the framework already knows how to read.

User secrets live in a JSON file in our user profile, tied to the project by a UserSecretsId GUID in the .csproj. On Windows that is %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json; on Linux and macOS it is ~/.microsoft/usersecrets/<id>/secrets.json.

Environment variables live in the operating system instead, scoped to a session, a user, or the whole machine depending on how they are set.

The framework wires both up for us. WebApplication.CreateBuilder adds environment variables always, and user secrets only when the environment is Development and the project has a UserSecretsId, which is the single most common source of “it works locally and not in production”.

Neither file nor variable is encrypted. Both are readable by anything running as us. What they buy is that the value is not in the repository and not in a colleague’s copy of it.

Those paths are for finding the file by hand, not for reading in code. Microsoft is explicit that the location and the format are implementation details that may change, and that “Secret Manager doesn’t encrypt the stored secrets and shouldn’t be treated as a trusted store. It’s for development purposes only.”

Both mechanisms also want their keys flat, though for different reasons. Environment variables have no hierarchy to keep, so the levels have to be encoded in the name. A secrets.json file is ordinary JSON and can hold nested objects perfectly well, but the dotnet user-secrets set and remove commands rewrite the whole file in flattened form, so a hand-nested file gets collapsed the next time a command touches it.

For example, a connection string looks like this in the appsettings.json file of the sample application we built in the previous part:

"ConnectionStrings": {
  "sqlConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True"
},

To read the same value from an environment variable, we write the key with a double underscore __ instead of the colon:

ConnectionStrings__sqlConnection

As a user secret, the colon stays exactly as it is in the configuration key path:

{
  "ConnectionStrings:sqlConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True"
}

Either way, the application reads it back with configuration.GetConnectionString("sqlConnection") and never learns which of the two supplied it. Our article on where a connection string should actually live goes through the rest of the options, and the development environment and how ASP.NET Core picks it explains the environment name both mechanisms depend on.

How Do We Use the Secret Manager?

User secrets are project-specific configuration values, which is what makes them convenient on a development machine holding dozens of projects. The tool that manages them is called the Secret Manager, and it ships with the .NET SDK, so there is nothing to install.

To start using secrets, we navigate to the project directory and enable them for that one project:

dotnet user-secrets init

As a result, the command creates an entry in the .csproj file:

<PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <UserSecretsId>77c3db5f-44fb-4632-bcb8-d535a49f7e20</UserSecretsId>
</PropertyGroup>

That GUID is the whole link between the project and its secret store. Two projects with two different GUIDs have two separate stores and cannot see each other’s values, which is exactly the property environment variables do not have.

We can also do this in Visual Studio by right-clicking on the project and selecting “Manage User Secrets”. That does the same two things the CLI does: it writes the UserSecretsId into the project file and opens the secrets.json file that belongs to it, from %APPDATA%\Microsoft\UserSecrets\ and not from anywhere inside our solution.

To create or modify a secret connection string, we use the set command:

dotnet user-secrets set "ConnectionStrings:sqlConnection" "Server=(localdb)\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True"

To check that it landed, we list the secrets for the project:

dotnet user-secrets list

secrets list

Now we can safely remove the value from the appsettings.json file, which is what the sample folder for this article ships with: no ConnectionStrings section at all, and the application refusing to start until we supply one from outside the file.

Opening that same store from Visual Studio shows the file the tool wrote, flat keys and all:

{
  "ConnectionStrings:sqlConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True",
  "Pages:HomePage:Color": "teal"
}

Removing a secret is as easy as setting one, and there is a command for emptying the whole store as well:

dotnet user-secrets remove "ConnectionStrings:sqlConnection"
dotnet user-secrets clear

If we already have a secrets file to bring into the project, we can pipe it into the set command instead of retyping every entry. That form is platform-specific, and here is the whole command set in one place:

CommandWhat it does
dotnet user-secrets initAdds a UserSecretsId GUID to the project file
dotnet user-secrets set "Key" "value"Stores or replaces one secret
dotnet user-secrets listPrints every secret for the project, values included
dotnet user-secrets remove "Key"Deletes one secret
dotnet user-secrets clearDeletes every secret for the project
type .\input.json | dotnet user-secrets setImports a batch of secrets from JSON, on Windows
cat ./input.json | dotnet user-secrets setImports a batch of secrets from JSON, on Linux and macOS
dotnet user-secrets <command> --project "path"Runs any of the above from outside the project directory

Every one of those commands assumes we are standing in the project directory. The --project option removes that assumption, so dotnet user-secrets list --project ".\ProjectConfigurationDemo" works from the solution folder or from a script.

One thing the tool cannot tell us is when the secrets are actually read. WebApplication.CreateBuilder registers the user-secrets provider only when the application runs in the Development environment, and only for a project that has a UserSecretsId. Miss either condition and there is no provider at all, which is why a secret that “stops working” is almost always a secret that was never loaded.

Using secrets doesn’t change the way we access our configuration either, so we can still use strongly typed objects to map these values.

How Do We Set Configuration With Environment Variables?

If we prefer environment variables, or we are working on a legacy project that already uses them, the mechanism is the same one from the previous section with a different separator: the hierarchy is flattened with a double underscore __, and ASP.NET Core turns it back into a colon when it reads the key. Both mechanisms are ordinary configuration sources, and part four of this series catalogues the built-in configuration providers, including these two, together with the order they run in.

So let’s see how we set them on each platform.

For Windows in CMD, we use the set command, with the quotes around the whole assignment:

set "ConnectionStrings__sqlConnection=Server=(localdb)\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True"

The quote placement is not a style choice. Writing set VAR="value" stores both quote characters as part of the value, so the application reads a connection string that begins and ends with a ", and set VAR=value without quotes keeps any trailing space that follows it on the command line. Wrapping the whole assignment is the only form that avoids both.

To review the variables we have set, set with no arguments lists all of them, and set ConnectionStrings__sqlConnection prints just the one.

We can use PowerShell too, and the short form is one line:

$env:ConnectionStrings__sqlConnection = 'Server=(localdb)\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True'

That value lives in the current session only. To keep it after the window closes, we set it against the user or the machine:

[System.Environment]::SetEnvironmentVariable('ConnectionStrings__sqlConnection','Server=(localdb)\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True',[System.EnvironmentVariableTarget]::User)

And we read it back with the matching getter:

[System.Environment]::GetEnvironmentVariable('ConnectionStrings__sqlConnection','User')

On Linux and macOS, export does the same job, and the value should of course be one the machine can actually reach:

export ConnectionStrings__sqlConnection="Server=localhost;Database=CodeMazeCommerce;User Id=sa;Password=<a strong password>;TrustServerCertificate=True"

To list them, printenv is the one to reach for. set works, but it prints shell variables and function definitions as well, so the answer is buried; printenv ConnectionStrings__sqlConnection prints just the one value. And unset ConnectionStrings__sqlConnection removes it.

Scope is the thing to keep in mind here, because it is where the two mechanisms genuinely differ. A variable set with set in CMD or with $env: in PowerShell belongs to that console window and is gone when it closes; the same is true of export in a shell, which is why a shell profile is where a permanent one goes. On Windows, only setx or the SetEnvironmentVariable call above survives into future windows, and neither affects the window we typed it in.

One thing environment variables are not is a way of protecting anything. They are plain text in the operating system, readable by every process running as us, so if the goal is protecting data inside the application rather than keeping it out of a commit, the data protection APIs and IDataProtector are the tool for that.

User Secrets or Environment Variables: Which Should We Use?

User secrets for local development, environment variables for everything that is not a person at a keyboard.

That split holds because of scope. User secrets are per project, so twenty repositories on one machine keep twenty separate stores and nothing collides. Environment variables are per machine or per session, so twenty projects that all want a key called ConnectionStrings__Default are one machine-wide value fighting over itself.

Environment variables win everywhere the Secret Manager cannot go. A build agent, a container, a systemd unit and a deployment slot all set environment variables and none of them has a user profile with a secrets.json in it.

Neither is a production answer. Both store plain text that any process running as the same user can read, so anything that leaves a developer machine needs a managed secret store instead.

Mixed is normal, and not a compromise. Most teams use secrets locally and variables in CI, and the reading code is identical either way.

User secretsEnvironment variables
ScopeOne project, identified by UserSecretsIdThe machine, the user account, or one shell session
Where the values liveA JSON file in the user profile, outside the repositoryThe operating system's environment
Hierarchy separator:, the same as configuration keys__, replaced with : when read
Loaded automaticallyOnly in the Development environment, and only for a project with a UserSecretsIdYes, in every environment
EncryptedNoNo
Survives a rebootYes, it is a fileOnly if set at user or machine scope
Intended for deploymentNo, development onlyYes
Best forLocal development on a shared codebaseDeployment, and anything running without a person at a keyboard

For the wider picture, including hosted vaults and the tooling around them, our article on secret management in ASP.NET Core covers the landscape this one deliberately stops at.

Getting this right once and then forgetting about it is the point, and the Ultimate ASP.NET Core Web API course sets it up the same way in the application this series is built from.

Conclusion

In this article, we’ve compared the two mechanisms ASP.NET Core gives us for keeping sensitive data out of source control on a development machine. User secrets are project-scoped, managed by a CLI that ships with the SDK, and loaded only in Development. Environment variables are machine-scoped or session-scoped, work everywhere, and are how the same values reach a build agent or a container.

Neither encrypts anything, which is the line where local development ends and a managed secret store begins.

You can find other parts of this series on the ASP.NET Core Web API page.

Tested with .NET 10.0.10.