User Secrets in Development

Keep local API keys and connection strings out of the repository with dotnet user-secrets, and know exactly when they load.

Syntaxdotnet user-secrets init dotnet user-secrets set "Payments:ApiKey" "<sandbox-key>" dotnet user-secrets list builder.Configuration.AddUserSecrets<Program>();

A developer needs a sandbox payment key and a local database password to run the API. Putting them in appsettings.Development.json commits them; putting them in shell profiles leaks them into every process. User secrets is the built-in answer: a JSON file per project, stored in your user profile, outside the source tree.

How it works

dotnet user-secrets init adds a <UserSecretsId> property to the .csproj. The SDK turns it into a UserSecretsIdAttribute on the compiled assembly. The ID is not secret; it only names a folder:

  • Windows: %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json
  • macOS and Linux: ~/.microsoft/usersecrets/<id>/secrets.json

dotnet user-secrets set "Payments:ApiKey" "..." writes a flattened key into that file; list, remove and clear do what they say. Piping a JSON file into dotnet user-secrets set imports many keys at once.

When they load

The default builders add the secrets file only when the environment is Development and the application assembly carries a UserSecretsIdAttribute. It sits after appsettings.{Environment}.json and before environment variables, so a secret overrides the JSON files and an environment variable overrides the secret. Under Staging, Production or a custom name such as Local, nothing is loaded unless you call AddUserSecrets<Program>() yourself.

What it is not

It is not encryption. The file is plain JSON readable by anything running as your user, including a compromised npm package or a backup tool. It only protects against the commonest leak: committing a key. Use sandbox or local-only credentials, never production ones. It is also not a deployment mechanism: there is no secrets file on a server or in a container, and production values come from environment variables, mounted files or a secret store.

Team decisions

ChoiceRecommendation
Where the list of required secrets livesIn the README or as empty keys in appsettings.json, plus options validation that names what is missing
API and worker sharing secretsGive both projects the same UserSecretsId; they then read one file
Local database passwordA throwaway value for a container on your machine, or Aspire parameters

Failure modes

Integration tests built with WebApplicationFactory run in Development by default, so they load your secrets on your machine and not on the CI agent: a test that passes locally fails in the pipeline. Make tests supply their own configuration. A second trap is precedence: a Payments__ApiKey variable left in your shell silently overrides the secret you just set, and the app seems to ignore it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Give each developer their own Stripe-style sandbox key for Payments:ApiKey without it ever appearing in a commit.
  • Share one local RabbitMQ password between the Orders API and the Orders worker by giving both the same UserSecretsId.
  • Explain why an integration test passes on a laptop but fails in CI: the laptop loaded a connection string from user secrets.

More examples

The day-to-day commands

Run the commands in the project folder or pass <code>--project src/Orders.Api</code>. Keys use colons because they are configuration keys, not environment variable names. <code>list</code> prints values in clear text, so avoid running it while sharing your screen. Importing from a JSON file is the quickest way to onboard a new developer: the team keeps a template with empty values, and each person fills in their own sandbox credentials.

Example · bash
cd src/Orders.Api
dotnet user-secrets init

dotnet user-secrets set "Payments:ApiKey" "<your-sandbox-key>"
dotnet user-secrets set "ConnectionStrings:Shop" "Host=localhost;Database=shop;Username=dev;Password=<local-password>"
dotnet user-secrets list

# Import many keys at once from a JSON file that stays outside the repository.
cat ~/shop-dev/orders-api.secrets.json | dotnet user-secrets set

dotnet user-secrets remove "Payments:ApiKey"
dotnet user-secrets clear

The project file after init

The SDK compiles <code>UserSecretsId</code> into a <code>UserSecretsIdAttribute</code>, which is how the builder finds the file at run time. Giving the API and its worker project the same ID makes them share one <code>secrets.json</code>, which suits services that need the same broker or database credentials. Two unrelated projects that copied a <code>.csproj</code> also share secrets by accident; generate a fresh ID with <code>init</code> in a new project.

Example · xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Written by dotnet user-secrets init. Not a secret: it only names the folder. -->
    <UserSecretsId>5d0c3f7e-2b8a-4c1e-9f1a-6e2d7b4c9a10</UserSecretsId>
  </PropertyGroup>

</Project>

Loading secrets outside Development

<code>AddUserSecrets&lt;Program&gt;()</code> reads the <code>UserSecretsIdAttribute</code> from the assembly that contains <code>Program</code>, so it also works from a test project or a console tool that references the API. Guard it with an environment check: calling it unconditionally means a production host would look for a secrets file, and a developer machine running the Release build against production settings would quietly mix in sandbox keys.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

// Some teams run locally with ASPNETCORE_ENVIRONMENT=Local and still want their secrets.
if (builder.Environment.IsEnvironment("Local"))
{
    builder.Configuration.AddUserSecrets<Program>(optional: true, reloadOnChange: true);

    // AddUserSecrets appends, which would put secrets above environment variables
    // and arguments. Add those again to keep the default precedence.
    builder.Configuration.AddEnvironmentVariables();
    builder.Configuration.AddCommandLine(args);
}

var app = builder.Build();

app.MapGet("/health/payments", (IConfiguration config) =>
    string.IsNullOrEmpty(config["Payments:ApiKey"])
        ? Results.Problem("Payments:ApiKey is not configured", statusCode: 503)
        : Results.Ok());

app.Run();

public partial class Program;

Discussion

  • Be the first to comment on this lesson.